From 317dcc0c34a3aa513b9079822b15b8228d655d35 Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Tue, 4 Aug 2026 13:21:30 +0100 Subject: [PATCH 01/22] feat(quarto-p2p): Phase 0 scaffold for preview live share over iroh (bd-9gam4jqe) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add crates/quarto-p2p to the workspace: public API stubs only (PreviewShareTicket, TunnelHost/TunnelClient + handles, TunnelStatus, TunnelError), all bodies todo!("Phase 1 (bd-v8mwzpmi)"). Deps per plan: iroh 1.0.3 (default features), iroh-tickets 1.0, subtle 2, rand 0.9, tokio with explicit io-util+net, tracing, thiserror. Adds the [workspace.dependencies.quarto-p2p] entry for Phase 2's consumer and commits the epic plan file. Gate 0 static checks re-confirmed on the real wiring: - WASM closure clean: cargo tree -i iroh from wasm-quarto-hub-client fails to match any package - dep set identical to what the gate measured: Cargo.lock additions are name+version-identical to the spike branch's (141 external packages; iroh 1.0.3 + iroh-tickets 1.0.0) — no Q4 re-measure needed - Windows compile leg pending a pushed CI run (test-suite CI has no Windows matrix); the gate proved this exact dep set on windows-latest 2026-08-04 (run 30894960520) cargo build --workspace green; cargo xtask verify --skip-hub-build passed all steps. Plan: claude-notes/plans/2026-08-03-q2-preview-live-share-iroh.md (Phase 0) --- Cargo.lock | 1788 ++++++++++++++++- Cargo.toml | 3 + .../2026-08-03-q2-preview-live-share-iroh.md | 1037 ++++++++++ crates/quarto-p2p/Cargo.toml | 28 + crates/quarto-p2p/src/lib.rs | 92 + 5 files changed, 2907 insertions(+), 41 deletions(-) create mode 100644 claude-notes/plans/2026-08-03-q2-preview-live-share-iroh.md create mode 100644 crates/quarto-p2p/Cargo.toml create mode 100644 crates/quarto-p2p/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 12c34ba40..e96c3a179 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,41 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.6", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "ahash" version = "0.8.12" @@ -118,6 +153,15 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + [[package]] name = "ariadne" version = "0.6.0" @@ -134,6 +178,18 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + [[package]] name = "async-channel" version = "2.5.0" @@ -175,6 +231,17 @@ dependencies = [ "syn", ] +[[package]] +name = "async_io_stream" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d7b9decdf35d8908a7e3ef02f64c5e9b1695e230154c0e8de3969142d9b94c" +dependencies = [ + "futures", + "pharos", + "rustc_version", +] + [[package]] name = "asynchronous-codec" version = "0.7.0" @@ -188,12 +255,33 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "atomic-polyfill" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" +dependencies = [ + "critical-section", +] + [[package]] name = "atomic-waker" version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "attohttpc" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16e2cdb6d5ed835199484bb92bb8b3edd526effe995c61732580439c1a67e2e9" +dependencies = [ + "base64 0.22.1", + "http", + "log", + "url", +] + [[package]] name = "auto_impl" version = "1.3.0" @@ -365,12 +453,29 @@ dependencies = [ "syn", ] +[[package]] +name = "backon" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" +dependencies = [ + "fastrand", + "gloo-timers", + "tokio", +] + [[package]] name = "base16ct" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +[[package]] +name = "base16ct" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" + [[package]] name = "base64" version = "0.21.7" @@ -416,6 +521,20 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +[[package]] +name = "blake3" +version = "1.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.0", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -434,6 +553,15 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + [[package]] name = "borsh" version = "1.6.1" @@ -512,6 +640,12 @@ dependencies = [ "shlex", ] +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + [[package]] name = "cfb" version = "0.7.3" @@ -531,9 +665,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" [[package]] name = "chacha20" @@ -587,6 +721,16 @@ dependencies = [ "half", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.6", + "inout", +] + [[package]] name = "clap" version = "4.6.1" @@ -744,6 +888,12 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + [[package]] name = "convert_case" version = "0.10.0" @@ -764,6 +914,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "cordyceps" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b9ab7e0ca1d179628fa0172b2b97203c7fa0cd81be2448bd446fb9559ca9261" +dependencies = [ + "loom", + "tracing", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -955,6 +1115,21 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-channel" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-deque" version = "0.8.6" @@ -1051,6 +1226,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" dependencies = [ "hybrid-array", + "rand_core 0.10.1", ] [[package]] @@ -1076,6 +1252,15 @@ dependencies = [ "syn", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "ctutils" version = "0.4.2" @@ -1095,8 +1280,26 @@ dependencies = [ "cpufeatures 0.2.17", "curve25519-dalek-derive", "digest 0.10.7", - "fiat-crypto", + "fiat-crypto 0.2.9", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "curve25519-dalek-derive", + "digest 0.11.2", + "fiat-crypto 0.3.0", + "rand_core 0.10.1", "rustc_version", + "serde", "subtle", "zeroize", ] @@ -1141,9 +1344,29 @@ dependencies = [ [[package]] name = "data-encoding" -version = "2.10.0" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "data-encoding-macro" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6a127ecbb3c4632e1525380e04c0c3fcf8dcb44d32a79ea290d8a36906edcd8" +dependencies = [ + "data-encoding", + "data-encoding-macro-internal", +] + +[[package]] +name = "data-encoding-macro-internal" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +checksum = "c54e03a951783e8b327515db3f2a2fd0e3bed362a96b066f341ce66ed49b4ead" +dependencies = [ + "data-encoding", + "syn", +] [[package]] name = "der" @@ -1152,7 +1375,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ "const-oid 0.9.6", - "pem-rfc7468", + "pem-rfc7468 0.7.0", + "zeroize", +] + +[[package]] +name = "der" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69dedd701da44b0536442edf09c81a64b0ab97a7a4a5e3d1971f00027cbc63d" +dependencies = [ + "const-oid 0.10.2", + "pem-rfc7468 1.0.0", "zeroize", ] @@ -1196,8 +1430,15 @@ dependencies = [ "quote", "rustc_version", "syn", + "unicode-xid", ] +[[package]] +name = "diatomic-waker" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab03c107fafeb3ee9f5925686dbb7a73bc76e3932abb0d2b365cb64b169cf04c" + [[package]] name = "diff" version = "0.1.13" @@ -1249,6 +1490,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.11.1", + "block2", + "libc", + "objc2", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -1260,6 +1513,17 @@ dependencies = [ "syn", ] +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "libc", + "once_cell", + "winapi", +] + [[package]] name = "document-features" version = "0.2.12" @@ -1302,12 +1566,12 @@ version = "0.16.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ - "der", + "der 0.7.10", "digest 0.10.7", "elliptic-curve", "rfc6979", - "signature", - "spki", + "signature 2.2.0", + "spki 0.7.3", ] [[package]] @@ -1316,8 +1580,19 @@ version = "2.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" dependencies = [ - "pkcs8", - "signature", + "pkcs8 0.10.2", + "signature 2.2.0", +] + +[[package]] +name = "ed25519" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" +dependencies = [ + "pkcs8 0.11.0", + "serdect", + "signature 3.0.0", ] [[package]] @@ -1326,14 +1601,30 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" dependencies = [ - "curve25519-dalek", - "ed25519", + "curve25519-dalek 4.1.3", + "ed25519 2.2.3", "serde", "sha2 0.10.9", "subtle", "zeroize", ] +[[package]] +name = "ed25519-dalek" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de" +dependencies = [ + "curve25519-dalek 5.0.0", + "ed25519 3.0.0", + "rand_core 0.10.1", + "serde", + "sha2 0.11.0", + "signature 3.0.0", + "subtle", + "zeroize", +] + [[package]] name = "ego-tree" version = "0.11.0" @@ -1352,15 +1643,15 @@ version = "0.13.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ - "base16ct", + "base16ct 0.2.0", "crypto-bigint", "digest 0.10.7", "ff", "generic-array", "group", "hkdf", - "pem-rfc7468", - "pkcs8", + "pem-rfc7468 0.7.0", + "pkcs8 0.10.2", "rand_core 0.6.4", "sec1", "subtle", @@ -1400,6 +1691,17 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5320ae4c3782150d900b79807611a59a99fc9a1d61d686faafc24b93fc8d7ca" +[[package]] +name = "enum-assoc" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed8956bd5c1f0415200516e78ff07ec9e16415ade83c056c230d7b7ea0d55b7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1476,6 +1778,12 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +[[package]] +name = "fiat-crypto" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" + [[package]] name = "filetime" version = "0.2.29" @@ -1591,6 +1899,19 @@ dependencies = [ "futures-util", ] +[[package]] +name = "futures-buffered" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4421cb78ee172b6b06080093479d3c50f058e7c81b7d577bbb8d118d551d4cd5" +dependencies = [ + "cordyceps", + "diatomic-waker", + "futures-core", + "pin-project-lite", + "spin 0.10.1", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -1630,7 +1951,10 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" dependencies = [ + "fastrand", "futures-core", + "futures-io", + "parking", "pin-project-lite", ] @@ -1689,6 +2013,21 @@ dependencies = [ "thread_local", ] +[[package]] +name = "generator" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3b854b0e584ead1a33f18b2fcad7cf7be18b3875c78816b753639aa501513ae" +dependencies = [ + "cc", + "cfg-if", + "libc", + "log", + "rustversion", + "windows-link", + "windows-result", +] + [[package]] name = "generic-array" version = "0.14.9" @@ -1743,11 +2082,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", "rand_core 0.10.1", "wasip2", "wasip3", + "wasm-bindgen", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", ] [[package]] @@ -1780,6 +2131,18 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "grass" version = "0.13.4" @@ -1847,8 +2210,17 @@ dependencies = [ ] [[package]] -name = "hashbrown" -version = "0.14.5" +name = "hash32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" dependencies = [ @@ -1880,6 +2252,11 @@ name = "hashbrown" version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] [[package]] name = "hashlink" @@ -1915,6 +2292,20 @@ dependencies = [ "http", ] +[[package]] +name = "heapless" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" +dependencies = [ + "atomic-polyfill", + "hash32", + "rustc_version", + "serde", + "spin 0.9.8", + "stable_deref_trait", +] + [[package]] name = "heck" version = "0.5.0" @@ -1943,6 +2334,83 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "hickory-net" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2295ed2f9c31e471e1428a8f88a3f0e1f4b27c15049592138d1eebe9c35b183" +dependencies = [ + "async-trait", + "bytes", + "cfg-if", + "data-encoding", + "futures-channel", + "futures-io", + "futures-util", + "h2", + "hickory-proto", + "http", + "idna", + "ipnet", + "jni 0.22.4", + "rand 0.10.1", + "rustls", + "thiserror 2.0.18", + "tinyvec", + "tokio", + "tokio-rustls", + "tracing", + "url", +] + +[[package]] +name = "hickory-proto" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bab31817bfb44672a252e97fe81cd0c18d1b2cf892108922f6818820df8c643" +dependencies = [ + "data-encoding", + "idna", + "ipnet", + "jni 0.22.4", + "once_cell", + "prefix-trie", + "rand 0.10.1", + "ring", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "url", +] + +[[package]] +name = "hickory-resolver" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d58d28879ceecde6607729660c2667a081ccdc082e082675042793960f178c" +dependencies = [ + "cfg-if", + "futures-util", + "hickory-net", + "hickory-proto", + "ipconfig", + "ipnet", + "jni 0.22.4", + "moka", + "ndk-context", + "once_cell", + "parking_lot", + "rand 0.10.1", + "resolv-conf", + "rustls", + "smallvec", + "system-configuration", + "thiserror 2.0.18", + "tokio", + "tokio-rustls", + "tracing", +] + [[package]] name = "hkdf" version = "0.12.4" @@ -2224,6 +2692,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "identity-hash" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfdd7caa900436d8f13b2346fe10257e0c05c1f1f9e351f4f5d57c03bd5f45da" + [[package]] name = "idna" version = "1.1.0" @@ -2245,6 +2719,26 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "igd-next" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de7238d487a9aff61f81b5ab41c0a841532a115a398b5fa92a2fadd0885e2581" +dependencies = [ + "attohttpc", + "bytes", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-util", + "log", + "rand 0.10.1", + "tokio", + "url", + "xmltree", +] + [[package]] name = "imagesize" version = "0.13.0" @@ -2311,6 +2805,15 @@ dependencies = [ "libc", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "inquire" version = "0.9.4" @@ -2340,11 +2843,27 @@ dependencies = [ "tempfile", ] +[[package]] +name = "ipconfig" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" +dependencies = [ + "socket2", + "widestring", + "windows-registry", + "windows-result", + "windows-sys 0.61.2", +] + [[package]] name = "ipnet" version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +dependencies = [ + "serde", +] [[package]] name = "iri-string" @@ -2356,6 +2875,186 @@ dependencies = [ "serde", ] +[[package]] +name = "iroh" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460de6bc52163b41b1646931f2897e5ab986f0966ade444467fec25024751a72" +dependencies = [ + "backon", + "blake3", + "bytes", + "cfg_aliases", + "ctutils", + "data-encoding", + "derive_more", + "ed25519-dalek 3.0.0", + "futures-util", + "getrandom 0.4.2", + "hickory-resolver", + "http", + "ipnet", + "iroh-base", + "iroh-dns", + "iroh-metrics", + "iroh-relay", + "n0-error", + "n0-future", + "n0-watcher", + "netwatch", + "noq", + "noq-proto", + "noq-udp", + "papaya", + "pin-project", + "portable-atomic", + "portmapper", + "rand 0.10.1", + "reqwest 0.13.3", + "rustc-hash", + "rustls", + "rustls-pki-types", + "serde", + "smallvec", + "strum", + "time", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", + "url", + "wasm-bindgen-futures 0.4.58 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "iroh-base" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6be73e16ee21c923aca9b3121aaa0db936f7c7ecc156ff47b8dac944c68d59a8" +dependencies = [ + "curve25519-dalek 5.0.0", + "data-encoding", + "data-encoding-macro", + "derive_more", + "ed25519-dalek 3.0.0", + "getrandom 0.4.2", + "n0-error", + "rand 0.10.1", + "serde", + "url", + "zeroize", +] + +[[package]] +name = "iroh-dns" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46f6a9b39d18e6345f5c151afd299f2488e2cb5c520fe41b107b6bd3dc4c3349" +dependencies = [ + "arc-swap", + "cfg_aliases", + "derive_more", + "hickory-resolver", + "iroh-base", + "n0-error", + "n0-future", + "ndk-context", + "portable-atomic", + "rand 0.10.1", + "rustls", + "simple-dns", + "strum", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "iroh-metrics" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291065721ad7c477b972e581bbc528df031dc8eb5e39fe1ff3300ae5dfb157ef" +dependencies = [ + "iroh-metrics-derive", + "itoa", + "n0-error", + "portable-atomic", + "ryu", + "serde", + "tracing", +] + +[[package]] +name = "iroh-metrics-derive" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae5f0c4405d1fbc9fb16ff422ca40620e93dc36c30ecaba0c2aee3992b7bd48" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "iroh-relay" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24bd586cf927f7b700f56ec3639b53cb5fa901ce284784051ff71092bfbf8193" +dependencies = [ + "blake3", + "bytes", + "cfg_aliases", + "data-encoding", + "derive_more", + "getrandom 0.4.2", + "hickory-resolver", + "http", + "http-body-util", + "hyper", + "hyper-util", + "iroh-base", + "iroh-dns", + "iroh-metrics", + "lru", + "n0-error", + "n0-future", + "noq", + "noq-proto", + "num_enum", + "pin-project", + "postcard", + "rand 0.10.1", + "reqwest 0.13.3", + "rustls", + "rustls-pki-types", + "serde", + "serde_bytes", + "strum", + "tokio", + "tokio-rustls", + "tokio-util", + "tokio-websockets", + "tracing", + "url", + "webpki-roots", + "ws_stream_wasm", +] + +[[package]] +name = "iroh-tickets" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da53233419ca36bf521ed45683b7748366f9b233032891eefc2d70567a84ac54" +dependencies = [ + "data-encoding", + "derive_more", + "iroh-base", + "n0-error", + "postcard", + "serde", +] + [[package]] name = "is-docker" version = "0.2.0" @@ -2402,6 +3101,22 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47f142fe24a9c9944451e8349de0a56af5f3e7226dc46f3ed4d4ecc0b85af75e" +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + [[package]] name = "jni" version = "0.22.4" @@ -2411,7 +3126,7 @@ dependencies = [ "cfg-if", "combine", "jni-macros", - "jni-sys", + "jni-sys 0.4.1", "log", "simd_cesu8", "thiserror 2.0.18", @@ -2432,6 +3147,15 @@ dependencies = [ "syn", ] +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + [[package]] name = "jni-sys" version = "0.4.1" @@ -2478,7 +3202,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0529410abe238729a60b108898784df8984c87f6054c9c4fcacc47e4803c1ce1" dependencies = [ "base64 0.22.1", - "ed25519-dalek", + "ed25519-dalek 2.2.0", "getrandom 0.2.17", "hmac 0.12.1", "js-sys", @@ -2490,7 +3214,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", - "signature", + "signature 2.2.0", "simple_asn1", ] @@ -2544,7 +3268,7 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" dependencies = [ - "spin", + "spin 0.9.8", ] [[package]] @@ -2613,6 +3337,28 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if", + "generator", + "scoped-tls", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "lru" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d2f2f9b4ba7e6b24d95e7e899329d35be83bcded72c8540cdd5368932d1d90a" +dependencies = [ + "hashbrown 0.17.0", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -2649,6 +3395,12 @@ dependencies = [ "which", ] +[[package]] +name = "mac-addr" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d25b0e0b648a86960ac23b7ad4abb9717601dec6f66c165f5b037f3f03065f" + [[package]] name = "mach2" version = "0.4.3" @@ -2791,6 +3543,76 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "moka" +version = "0.12.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + +[[package]] +name = "n0-error" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c37e81176a83a77d2514528b91bdafc70ef88aab428f0e1b91aebb8d99888895" +dependencies = [ + "n0-error-macros", + "spez", +] + +[[package]] +name = "n0-error-macros" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2acd8b070213b0299282f884b4beba4e7b52d624fdcd504a3ad3665390c11e1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "n0-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2ab99dfb861450e68853d34ae665243a88b8c493d01ba957321a1e9b2312bbe" +dependencies = [ + "cfg_aliases", + "derive_more", + "futures-buffered", + "futures-lite", + "futures-util", + "js-sys", + "pin-project", + "send_wrapper", + "tokio", + "tokio-util", + "wasm-bindgen", + "wasm-bindgen-futures 0.4.58 (registry+https://github.com/rust-lang/crates.io-index)", + "web-time", +] + +[[package]] +name = "n0-watcher" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc618745ad0b7414b149d0517ad8b5573b2fb4d4e2717add3d2446ce1fdd826" +dependencies = [ + "derive_more", + "n0-error", + "n0-future", +] + [[package]] name = "native-tls" version = "0.2.18" @@ -2809,10 +3631,191 @@ dependencies = [ ] [[package]] -name = "new_debug_unreachable" -version = "1.0.6" +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "netdev" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "569dfbdd2efd771b24ec9bb57f956e04d4fbfc72f62b2f11961723f9b3f4b020" +dependencies = [ + "block2", + "dispatch2", + "dlopen2", + "ipnet", + "jni 0.21.1", + "libc", + "mac-addr", + "ndk-context", + "netlink-packet-core", + "netlink-packet-route", + "netlink-sys", + "objc2", + "objc2-core-foundation", + "objc2-core-wlan", + "objc2-foundation", + "objc2-system-configuration", + "once_cell", + "plist", + "windows-sys 0.61.2", +] + +[[package]] +name = "netlink-packet-core" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3463cbb78394cb0141e2c926b93fc2197e473394b761986eca3b9da2c63ae0f4" +dependencies = [ + "paste", +] + +[[package]] +name = "netlink-packet-route" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2288fcb784eb3defd5fb16f4c4160d5f477de192eac730f43e1d11c24d9a007" +dependencies = [ + "bitflags 2.11.1", + "libc", + "log", + "netlink-packet-core", +] + +[[package]] +name = "netlink-proto" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6f7398dddf5f152d2a91a2921a134c6097056e292c0d4b9906007855e7cece6" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "log", + "netlink-packet-core", + "netlink-sys", + "thiserror 2.0.18", +] + +[[package]] +name = "netlink-sys" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd6c30ed10fa69cc491d491b85cc971f6bdeb8e7367b7cde2ee6cc878d583fae" +dependencies = [ + "bytes", + "futures-util", + "libc", + "log", + "tokio", +] + +[[package]] +name = "netwatch" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d9cbe01741347ef750d743d6690603f5eed8341e679fb51c8e629337aa11976" +dependencies = [ + "atomic-waker", + "bytes", + "cfg_aliases", + "derive_more", + "ipnet", + "js-sys", + "libc", + "n0-error", + "n0-future", + "n0-watcher", + "netdev", + "netlink-packet-core", + "netlink-packet-route", + "netlink-proto", + "netlink-sys", + "noq-udp", + "objc2-core-foundation", + "objc2-system-configuration", + "pin-project-lite", + "serde", + "socket2", + "time", + "tokio", + "tokio-util", + "tracing", + "web-sys", + "windows", + "windows-result", + "wmi", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "noq" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e4bb6601fa543c110d8957813267d5a8d775a0f8fbaccf1f615d06ba9b10da" +dependencies = [ + "bytes", + "cfg_aliases", + "derive_more", + "noq-proto", + "noq-udp", + "pin-project-lite", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tracing", + "web-time", +] + +[[package]] +name = "noq-proto" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa7b5ccd819a9c68a0d955e67a881032d09b1a17219b1f90b0997a0888e1a15" +dependencies = [ + "aes-gcm", + "bytes", + "derive_more", + "enum-assoc", + "getrandom 0.4.2", + "identity-hash", + "lru-slab", + "rand 0.10.1", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "sorted-index-buffer", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "noq-udp" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +checksum = "02bba20e097a5a16cd0ad14ec882fae1e80a092a124e9422fc4dddd92e96a647" +dependencies = [ + "cfg_aliases", + "libc", + "socket2", + "tracing", + "windows-sys 0.61.2", +] [[package]] name = "notify" @@ -2924,6 +3927,118 @@ dependencies = [ "libm", ] +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.11.1", + "block2", + "dispatch2", + "libc", + "objc2", +] + +[[package]] +name = "objc2-core-wlan" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c71e34919aba0d701380d911702455038a8a3587467fe0141d6a71501e7ffe48" +dependencies = [ + "bitflags 2.11.1", + "objc2", + "objc2-core-foundation", + "objc2-foundation", + "objc2-security", + "objc2-security-foundation", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.11.1", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-security" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" +dependencies = [ + "bitflags 2.11.1", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-security-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef76382e9cedd18123099f17638715cc3d81dba3637d4c0d39ab69df2ef345a5" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-system-configuration" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" +dependencies = [ + "bitflags 2.11.1", + "dispatch2", + "libc", + "objc2", + "objc2-core-foundation", + "objc2-security", +] + [[package]] name = "object" version = "0.37.3" @@ -2941,6 +4056,10 @@ name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +dependencies = [ + "critical-section", + "portable-atomic", +] [[package]] name = "once_cell_polyfill" @@ -2954,6 +4073,12 @@ version = "11.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "open" version = "5.3.5" @@ -3107,6 +4232,16 @@ dependencies = [ "yaml-rust2", ] +[[package]] +name = "papaya" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "997ee03cd38c01469a7046643714f0ad28880bcb9e6679ff0666e24817ca19b7" +dependencies = [ + "equivalent", + "seize", +] + [[package]] name = "parking" version = "2.2.1" @@ -3167,6 +4302,15 @@ dependencies = [ "base64ct", ] +[[package]] +name = "pem-rfc7468" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -3226,6 +4370,16 @@ dependencies = [ "sha2 0.10.9", ] +[[package]] +name = "pharos" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9567389417feee6ce15dd6527a8a1ecac205ef62c2932bcf3d9f6fc5b78b414" +dependencies = [ + "futures", + "rustc_version", +] + [[package]] name = "phf" version = "0.11.3" @@ -3353,9 +4507,9 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" dependencies = [ - "der", - "pkcs8", - "spki", + "der 0.7.10", + "pkcs8 0.10.2", + "spki 0.7.3", ] [[package]] @@ -3364,8 +4518,18 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" dependencies = [ - "der", - "spki", + "der 0.7.10", + "spki 0.7.3", +] + +[[package]] +name = "pkcs8" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" +dependencies = [ + "der 0.8.1", + "spki 0.8.0", ] [[package]] @@ -3374,6 +4538,19 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "plist" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" +dependencies = [ + "base64 0.22.1", + "indexmap", + "quick-xml 0.41.0", + "serde", + "time", +] + [[package]] name = "polling" version = "3.11.0" @@ -3394,6 +4571,56 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "portable-atomic" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" +dependencies = [ + "serde", +] + +[[package]] +name = "portmapper" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb3713e4977408279158444a18c1a01ac9bf2e7eaf1fbfd1a19ac9cd18d90721" +dependencies = [ + "base64 0.22.1", + "bytes", + "derive_more", + "hyper-util", + "igd-next", + "iroh-metrics", + "libc", + "n0-error", + "n0-future", + "netwatch", + "num_enum", + "rand 0.10.1", + "serde", + "smallvec", + "socket2", + "time", + "tokio", + "tokio-util", + "tower-layer", + "tracing", + "url", +] + [[package]] name = "postcard" version = "1.1.3" @@ -3403,9 +4630,22 @@ dependencies = [ "cobs", "embedded-io 0.4.0", "embedded-io 0.6.1", + "heapless", + "postcard-derive", "serde", ] +[[package]] +name = "postcard-derive" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0232bd009a197ceec9cc881ba46f727fcd8060a2d8d6a9dde7a69030a6fe2bb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -3436,6 +4676,17 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" +[[package]] +name = "prefix-trie" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cf6e3177f0684016a5c209b00882e15f8bdd3f3bb48f0491df10cd102d0c6e7" +dependencies = [ + "either", + "ipnet", + "num-traits", +] + [[package]] name = "pretty_assertions" version = "1.4.1" @@ -3465,6 +4716,15 @@ dependencies = [ "elliptic-curve", ] +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -3944,6 +5204,19 @@ dependencies = [ "yaml-rust2", ] +[[package]] +name = "quarto-p2p" +version = "0.13.0" +dependencies = [ + "iroh", + "iroh-tickets", + "rand 0.9.4", + "subtle", + "thiserror 2.0.18", + "tokio", + "tracing", +] + [[package]] name = "quarto-pandoc-types" version = "0.0.0" @@ -4171,7 +5444,7 @@ dependencies = [ "insta", "quarto-error-reporting", "quarto-source-map", - "quick-xml", + "quick-xml 0.39.3", "thiserror 2.0.18", ] @@ -4202,6 +5475,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + [[package]] name = "quinn" version = "0.11.9" @@ -4355,6 +5637,15 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "rand_xorshift" version = "0.4.0" @@ -4558,15 +5849,23 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-rustls", + "tokio-util", "tower 0.5.3", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures 0.4.58 (registry+https://github.com/rust-lang/crates.io-index)", + "wasm-streams", "web-sys", ] +[[package]] +name = "resolv-conf" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" + [[package]] name = "rfc6979" version = "0.4.0" @@ -4603,10 +5902,10 @@ dependencies = [ "num-integer", "num-traits", "pkcs1", - "pkcs8", + "pkcs8 0.10.2", "rand_core 0.6.4", - "signature", - "spki", + "signature 2.2.0", + "spki 0.7.3", "subtle", "zeroize", ] @@ -4743,7 +6042,7 @@ checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" dependencies = [ "core-foundation 0.10.1", "core-foundation-sys", - "jni", + "jni 0.22.4", "log", "once_cell", "rustls", @@ -4899,6 +6198,12 @@ dependencies = [ "syn", ] +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + [[package]] name = "scopeguard" version = "1.2.0" @@ -4935,10 +6240,10 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" dependencies = [ - "base16ct", - "der", + "base16ct 0.2.0", + "der 0.7.10", "generic-array", - "pkcs8", + "pkcs8 0.10.2", "subtle", "zeroize", ] @@ -4966,6 +6271,16 @@ dependencies = [ "libc", ] +[[package]] +name = "seize" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "selectors" version = "0.36.1" @@ -4991,6 +6306,12 @@ version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" + [[package]] name = "serde" version = "1.0.228" @@ -5011,6 +6332,16 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -5103,6 +6434,16 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "serdect" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" +dependencies = [ + "base16ct 1.0.0", + "serde", +] + [[package]] name = "servo_arc" version = "0.4.3" @@ -5227,6 +6568,15 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "simd-adler32" version = "0.3.9" @@ -5264,6 +6614,15 @@ dependencies = [ "bstr", ] +[[package]] +name = "simple-dns" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a75cbde1bf934313596a004973e462f9a82caa814dcf1a5f507bdf51597eeb4" +dependencies = [ + "bitflags 2.11.1", +] + [[package]] name = "simple_asn1" version = "0.6.4" @@ -5317,11 +6676,37 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "sorted-index-buffer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea06cc588e43c632923a55450401b8f25e628131571d4e1baea1bdfdb2b5ed06" + +[[package]] +name = "spez" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c87e960f4dca2788eeb86bbdde8dd246be8948790b7618d656e68f9b720a86e8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "spin" version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spin" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3" [[package]] name = "spki" @@ -5330,7 +6715,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" dependencies = [ "base64ct", - "der", + "der 0.7.10", +] + +[[package]] +name = "spki" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" +dependencies = [ + "base64ct", + "der 0.8.1", ] [[package]] @@ -5375,6 +6770,27 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "subtle" version = "2.6.1" @@ -5439,6 +6855,12 @@ dependencies = [ "libc", ] +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + [[package]] name = "tar" version = "0.4.46" @@ -5642,6 +7064,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + [[package]] name = "tokio-tungstenite" version = "0.27.0" @@ -5678,10 +7112,64 @@ dependencies = [ "futures-core", "futures-io", "futures-sink", + "futures-util", "pin-project-lite", "tokio", ] +[[package]] +name = "tokio-websockets" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52efb639344a7c6adb8e62c6f3d2c19c001ff1b79a5041ba1c6ed42e19c6aa5" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-sink", + "getrandom 0.4.2", + "http", + "httparse", + "rand 0.10.1", + "ring", + "rustls-pki-types", + "sha1_smol", + "simdutf8", + "tokio", + "tokio-rustls", + "tokio-util", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + [[package]] name = "tower" version = "0.4.13" @@ -6111,6 +7599,16 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.6", + "subtle", +] + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -6427,6 +7925,19 @@ dependencies = [ name = "wasm-printf-fmt" version = "0.14.0" +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures 0.4.58 (registry+https://github.com/rust-lang/crates.io-index)", + "web-sys", +] + [[package]] name = "wasmparser" version = "0.236.1" @@ -6740,6 +8251,12 @@ dependencies = [ "libc", ] +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + [[package]] name = "win_uds" version = "0.2.2" @@ -6802,6 +8319,27 @@ dependencies = [ "wasmtime-internal-math", ] +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -6815,6 +8353,17 @@ dependencies = [ "windows-strings", ] +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -6843,6 +8392,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + [[package]] name = "windows-registry" version = "0.6.1" @@ -6872,6 +8431,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -6899,6 +8467,21 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -6932,6 +8515,21 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -6944,6 +8542,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -6956,6 +8560,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -6980,6 +8590,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -6992,6 +8608,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -7004,6 +8626,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -7016,6 +8644,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -7028,6 +8662,15 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -7122,12 +8765,46 @@ dependencies = [ "wasmparser 0.244.0", ] +[[package]] +name = "wmi" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c81b85c57a57500e56669586496bf2abd5cf082b9d32995251185d105208b64" +dependencies = [ + "chrono", + "futures", + "log", + "serde", + "thiserror 2.0.18", + "windows", + "windows-core", +] + [[package]] name = "writeable" version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "ws_stream_wasm" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c173014acad22e83f16403ee360115b38846fe754e735c5d9d3803fe70c6abc" +dependencies = [ + "async_io_stream", + "futures", + "js-sys", + "log", + "pharos", + "rustc_version", + "send_wrapper", + "thiserror 2.0.18", + "wasm-bindgen", + "wasm-bindgen-futures 0.4.58 (registry+https://github.com/rust-lang/crates.io-index)", + "web-sys", +] + [[package]] name = "xattr" version = "1.6.1" @@ -7138,6 +8815,21 @@ dependencies = [ "rustix", ] +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + +[[package]] +name = "xmltree" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7d8a75eaf6557bb84a65ace8609883db44a29951042ada9b393151532e41fcb" +dependencies = [ + "xml-rs", +] + [[package]] name = "xtask" version = "0.1.0" @@ -7237,9 +8929,23 @@ dependencies = [ [[package]] name = "zeroize" -version = "1.8.2" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "zeromq" diff --git a/Cargo.toml b/Cargo.toml index 66acb0515..b1817b9e1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -174,6 +174,9 @@ path = "./crates/quarto-hub" [workspace.dependencies.quarto-navigation] path = "./crates/quarto-navigation" +[workspace.dependencies.quarto-p2p] +path = "./crates/quarto-p2p" + [workspace.dependencies.quarto-publish] path = "./crates/quarto-publish" diff --git a/claude-notes/plans/2026-08-03-q2-preview-live-share-iroh.md b/claude-notes/plans/2026-08-03-q2-preview-live-share-iroh.md new file mode 100644 index 000000000..63de24d12 --- /dev/null +++ b/claude-notes/plans/2026-08-03-q2-preview-live-share-iroh.md @@ -0,0 +1,1037 @@ +--- +title: "q2 preview live share over iroh (`--share` / `--join`)" +date: 2026-08-03 +status: planned +branch: feature/preview-live-share (integration line; sub-tasks on braid/-) +braid: bd-yyoyvx91 +design-input: user design doc "Creating q2 preview with iroh" (2026-08-03 session) +verified: "file:line claims checked against ../iroh @ v1.0.3, ../iroh-tickets @ 1.0.0, ../samod, and this tree" +--- + +# q2 preview live share over iroh + +Goal: "VS Code Live Share, but built into `q2`." A host runs +`q2 preview --share` and gets a single join string; a guest runs +`q2 preview --join ` on another machine and gets a browser tab showing +the same live preview session — automerge sync, presence, and engine captures +included — over an end-to-end-encrypted iroh P2P connection (relay fallback +when hole-punching fails). + +## Explicit decision reversal + +The preview epic (`claude-notes/plans/2026-05-11-q2-preview-epic.md:578-580`) +declared "multi-user collaborative preview" out of scope ("real collaboration +belongs in `quarto hub`, not `quarto preview`"). This plan reverses that, per +the design doc: ephemeral, zero-setup collaboration is exactly the +niche `q2 preview` fills that a persistent hub does not. The epic's +never-implemented Phase E `--share ` idea (broadcast to a remote sync +server; an unnumbered bullet at :313-315 — there is no "E.3" label) is +superseded: our `--share` takes no value and hosts P2P; joining a hosted hub +is Phase 5 of this plan, from the other direction (`--join `). + +## Architecture decision: authenticated TCP-over-iroh tunnel + +**The joiner is a dumb, token-authenticated TCP proxy. The host's existing +preview HTTP server serves everything — SPA assets, `/health`, `/ws`, +`/api/preview/*`, project artifacts — through iroh QUIC streams.** + +``` +HOST GUEST +q2 preview --share q2 preview --join +┌────────────────────────────┐ ┌──────────────────────────┐ +│ preview hub (axum) │ │ TcpListener 127.0.0.1:N │ +│ 127.0.0.1:PORT │ │ 1 TCP conn = 1 QUIC │ +│ /health /ws /api /assets │ │ bi-stream, token- │ +│ ▲ │ │ prefixed │ +│ TunnelHost │ TcpStream per │ iroh (QUIC, │ ▲ │ +│ (iroh │ bi-stream │◄──E2E-encrypted,──►│ TunnelClient │ +│ Router) │ │ hole-punch or │ (iroh Endpoint) │ +└────────────┴───────────────┘ n0 relay) └────────────┴─────────────┘ + ▲ + browser: http://127.0.0.1:N/ +``` + +Why this shape wins (each alternative was seriously considered): + +1. **The preview SPA hard-assumes same-origin.** `deriveWsUrl()` derives + `ws(s)://{location.host}/ws` (`q2-preview-spa/src/PreviewApp.tsx:539-542`) + and the index doc id comes from same-origin `GET /health` + (`PreviewApp.tsx:499-514`). A local proxy makes both true by construction — + zero SPA changes for the MVP. +2. **The boot/reconnect supervisor keeps working.** `bootController.ts`'s + contract is "HTTP `/health` decides liveness; the WebSocket gets patience" + (`q2-preview-spa/src/bootController.ts:1-20`). With a tunnel, `/health` + through the local port genuinely probes the remote host: tunnel dies → + health strikes → banner → HTTP-only polling → recovery. For free. +3. **Rendering is already client-side.** The SPA renders via WASM in the + guest's browser; after the one-time asset download, tunnel traffic is just + automerge sync frames, presence ephemerals, and occasional artifact + fetches. A "native" samod-over-iroh transport would not move rendering + anywhere better. +4. **No version skew.** The guest's browser runs the host's SPA build, so SPA + ↔ sync-protocol ↔ schema versions always match. A joiner that served its + own SPA would need a compatibility story. +5. **Engine captures, artifacts, re-execute all work.** `/.quarto/project-artifacts/*` + and `/api/preview/re-execute` read host-local state that automerge does not + carry; proxying HTTP is the only design in which these work without new + protocol surface. +6. **No TCP-over-TCP pathology.** The tunnel is a *terminated splice*, not + encapsulation: the TCP legs are loopback on each end and only QUIC runs on + the WAN, so exactly one congestion controller governs the wide-area path. + +**Scope note: multiple concurrent guests are in scope for v1.** Each guest is +an independent iroh connection into the same Router accept loop, all sharing +the one session token; per-peer distinction exists only in host logs +(`remote_id()`), not in permissions (risk #4). Nothing in the design is +single-guest-shaped; Gate 0 Q1 carries an optional multi-guest sanity check, +and Phase 3's end-to-end check joins two guests at least once. + +**Alternative rejected (for v1): samod-native iroh transport.** samod's +`Transport::from_tokio_io` accepts any `AsyncRead + AsyncWrite` (samod +checkout `samod/src/transport.rs:54-81`), `HubContext::acceptor()` is public +(`crates/quarto-hub/src/context.rs:441`), and `BearerDialer` +(`crates/quarto-hub-provider/src/dialer.rs`) is precedent for a custom +`samod::Dialer` — so a first-class `IrohDialer`/iroh acceptor is quite +feasible. The decisive count: **the guest needs five +surfaces from the host — SPA assets, `/health` (index doc id), +`/api/preview/config` (allow-edit), `/ws` (sync + presence), and +`/.quarto/project-artifacts/*` + `/api/preview/re-execute` — and a native +samod transport replaces exactly one of them.** The HTTP tunnel must exist +regardless, so "native transport" means a *second* transport, auth +checkpoint, and reconnect lifecycle alongside it, to save only WebSocket +framing on the smallest, most latency-tolerant traffic class (sync frames; +the heavy traffic — assets, WASM, artifacts — is HTTP either way). Auth +bypass (checks live in the axum `ws_handler`, +`crates/quarto-hub/src/server.rs:1602-1648`; the samod `AccessPolicy` impl — +concrete type `AuditAccessPolicy` — is allow-all, with the unconditional +`true` at `access_policy.rs:62`) is the *cheapest* objection to fix with a new auth +endpoint — the load-bearing ones are the same-origin assumptions, the +no-version-skew guarantee, and the artifacts/re-execute surface, which an +auth endpoint does not touch. For accuracy: the "guest as intermediate samod +peer" variant would *not* break presence — samod gossips ephemerals through +intermediate peers (verified: `samod-core/src/actors/document/document_actor.rs:319-333` +forwards ephemeral messages to all other connections) — but it would break +the boot supervisor's "HTTP `/health` decides liveness" contract (a local +`/health` always answers even when the host is gone) and grow the "separate +auth endpoint" into a full sidecar API for doc id / allow-edit / artifacts / +re-execute. Keep as future work for native-peer use cases (e.g. +`q2 provide-hub` over iroh), where samod-over-iroh replaces the *entire* +surface, not a fifth of it. + +## iroh facts this plan depends on (verified against ../iroh @ v1.0.3) + +The local checkout is **iroh 1.0.3** — a hard API break from pre-1.0 +tutorials. Published on crates.io; MIT OR Apache-2.0; MSRV 1.91 (we're on +nightly-2026-04-28 / 1.97 — fine); edition 2024 (we already use it). + +- `Endpoint::builder(preset)` / `Endpoint::bind(preset)` — the preset is + **mandatory** (`iroh/src/endpoint.rs:952,957`). `presets::N0` = n0 pkarr + publish/resolve + DNS lookup + default relays + ring crypto + (`iroh/src/endpoint/presets.rs:113`). Tests use `presets::Minimal` (crypto + only) + `RelayMode::Disabled` + explicit loopback addrs for hermeticity + (Minimal-built endpoints already default to `RelayMode::Disabled`; the + explicit setting is documentation). +- Names are `EndpointId` / `EndpointAddr` / `TransportAddr::{Relay,Ip,Custom}` + (`iroh-base/src/endpoint_addr.rs:41-54`). `NodeId`/`NodeAddr`/ + `discovery_n0()` no longer exist. +- `Router::builder(ep).accept(ALPN, handler).spawn()` registers ALPNs itself + (`iroh/src/protocol.rs:502-511`); `ProtocolHandler::accept(Connection)` runs + on its own task and may be long-lived (`protocol.rs:262-268`). Router is + abort-on-drop: store it, await `shutdown()`. +- `open_bi`/`accept_bi` yield `(SendStream, RecvStream)`; `RecvStream: AsyncRead`, + `SendStream: AsyncWrite`, and `SendStream::poll_shutdown` calls `finish()` — + a QUIC FIN, exactly the half-close mapping the splice needs (via `noq` + 1.1.0, n0's quinn fork; `send_stream.rs:345-347`). Use + `tokio::io::join(recv, send)` + `tokio::io::copy_bidirectional` for splicing. +- **Stream laziness:** `open_bi()` does not wake the peer's `accept_bi()` + until the opener writes bytes (`iroh/src/lib.rs:150-158`). Our per-stream + token prefix satisfies this by construction. +- `endpoint.online().await` **pends forever with no relay reachable**; wrap in + `tokio::time::timeout(Duration::from_secs(10), …)` with our own constant. + **Do not use `iroh::NET_REPORT_TIMEOUT`** — that re-export is + `net_report::TIMEOUT`, a bare `u64 = 5` intended for docs (not a `Duration`, + not 10 s; passing it to `timeout()` is a compile error). The 10 s budget we + want matches iroh's *private* `defaults.rs:129` constant. + Only after it resolves does `endpoint.addr()` contain a relay URL. +- Tickets live in a separate crate, **`iroh-tickets` 1.0.0** (verified against + ../iroh-tickets). Trait `Ticket` requires `KIND` + + `encode_bytes`/`decode_bytes` (body format is implementer-supplied; postcard + is the recommendation we follow); the provided `encode_string`/ + `decode_string` produce lowercase-`KIND`-prefix + BASE32_NOPAD and reject + foreign kinds. We implement `Ticket` for our own struct rather than + shipping a bare `EndpointTicket` (KIND `"endpoint"`), because the join + string must also carry the session token. +- Graceful close: `router.shutdown().await?` alone suffices — it already + calls `Endpoint::close` (`protocol.rs:421-423`) and returns + `Result<(), JoinError>` which must be handled (≈3 s worst case); a trailing + `endpoint.close().await` is an idempotent no-op. `Connection::close()` is + sync and only queues. +- Default n0 relays need no auth; behind NAT the relay path is the designed + fallback (worst case: relayed throughput, not failure). +- No in-tree TCP↔QUIC bridge exists; we write the ~30-line splice ourselves. + `iroh/examples/echo-no-router.rs`, `auth-hook.rs`, `search.rs` are the + patterns to crib from. + +## Security model + +- **Join string = capability.** `q2preview…` ticket carries the host's + `EndpointAddr` **and a random 256-bit session token**. QUIC gives + confidentiality + host authentication (the joiner dials the pinned + `EndpointId` from the ticket — MITM-proof); the token authenticates the + *guest* to the host. +- **Per-stream token prefix.** Every bi-stream starts with the raw 32-byte + token; the host `read_exact`s it under a timeout and compares with + `subtle::ConstantTimeEq` before connecting the backing `TcpStream`. + Stateless, no extra RTT (bytes pipeline behind the token), and it satisfies + iroh's write-first rule. Mismatch → stream reset + connection close + log. +- **The local HTTP port stays loopback-bound.** `--share` adds no listening + TCP surface; the only remote path is the authenticated tunnel. (This + sidesteps the never-implemented `--insecure-allow-network` posture from + epic Q7, `2026-05-11-q2-preview-epic.md:416-431`.) +- **What the token grants — must be printed at share time:** guests can view + the project, trigger `POST /api/preview/re-execute` (engine execution on the + host = code execution by design), and, iff the host passed `--allow-edit`, + write edits that persist to the host's disk. Per-peer permissions are + explicitly out of scope for v1 (`--allow-edit` is a process-wide + `OnceLock` + one `DiskWritePolicy`; see follow-ups). +- Log each joining peer's `EndpointId` (`conn.remote_id().fmt_short()`) on the + host for auditability. +- Privacy note: `presets::N0` publishes the host's addresses (including LAN + IPs) to n0's pkarr/DNS. Acceptable for v1; `AddrFilter::relay_only()` is the + knob if we want to change that (follow-up). + +## CLI surface (decided) + +| Flag | Meaning | +|---|---| +| `q2 preview --share` | Host: normal preview + iroh tunnel host; prints ticket + ready-to-paste `q2 preview --join …` line | +| `q2 preview --join ` | Guest: local loopback proxy + browser; no local project, no hub, no SPA of its own | +| `q2 preview --ui ` | Which embedded frontend the server serves. `viewer` (default): the read-only preview SPA; `editor`: the full hub-client editor. Orthogonal to `--allow-edit` — UI choice never changes the disk write policy | + +- **`--ui` replaces the earlier `--with-editor` proposal.** + An enum because the flag *substitutes* which embedded dist `spa_handler` + serves (it is not additive); `viewer` rather than `preview` because + `q2 preview --ui preview` is a tautology — "preview" names the session, not + the frontend. Crucially, **`--ui editor` does not imply `--allow-edit`**: + UI and write policy are a real 2×2 — the viewer already has an allow-edit + inline-edit surface (`PreviewApp.tsx:523-532`), and + `DiskWritePolicy::ReadOnly` already models "session edits sync live, disk + stays authoritative" (`crates/quarto-hub/src/sync.rs:35-57`). So + `--share --ui editor` *without* `--allow-edit` is a deliberate sandbox + mode: guests get the full editor, their edits drive everyone's live + session, and nothing persists to the host's disk. (Caveat to document: + under `ReadOnly` a session edit survives only until the next host-side + filesystem change converges that doc back to disk content — `sync.rs:41-45`; + binary docs revert immediately.) Running `--ui editor` + without `--allow-edit` prints: "session edits are ephemeral — pass + `--allow-edit` to persist to disk". Symmetrically, `--share` does **not** + imply `--ui editor` (that would couple Phase 2 to Phase 4 and make the + heavy editor bundle the default relay-path boot experience); if usage + later wants editor-by-default when sharing, the mechanism is an + overridable `--ui auto` default — explicitly a non-goal for v1. +- **`--host` is unavailable for hosting semantics** — it is already the bind + interface (`crates/quarto/src/main.rs:212-213`). It keeps that meaning + (and, for `--join`, binds the local proxy listener; default `127.0.0.1`). +- Compositions: `--share --ui editor` (guests get the full editor, served by + the host; sandbox unless `--allow-edit`); `--share --ui editor --allow-edit` + (full collaborative editing, persists to host disk); `--share --allow-edit` + (viewer with inline-edit write-back); `--join --port N`; `--join --no-browser`. +- Conflicts (clap `conflicts_with`): `--join` × {`path`, `--share`, + `--no-project`, `--allow-edit`, `--ui`, `--data-dir`, + `--preview-dir`}. +- ALPN: `b"q2/preview-tunnel/0"`. Ticket KIND: `"q2preview"` (KIND string is + the protocol version tag; breaking change ⇒ new KIND). + +## New crate: `crates/quarto-p2p` + +Encapsulates all iroh usage; `quarto-preview` and `crates/quarto` see only: + +```rust +pub struct PreviewShareTicket { pub addr: EndpointAddr, pub token: [u8; 32] } + // impl iroh_tickets::Ticket (KIND "q2preview"), Display/FromStr, Debug redacts token + +pub struct TunnelHost; // TunnelHost::spawn(cfg, target: SocketAddr) -> (PreviewShareTicket, TunnelHostHandle) +pub struct TunnelClient; // TunnelClient::bind(ticket, local: SocketAddr) -> (SocketAddr, TunnelClientHandle) + // handles: async shutdown(); client: status watch (Connected/Reconnecting) for CLI messaging +``` + +Native-only (not in the WASM closure — `wasm-quarto-hub-client` depends on +`quarto-core`/`pampa`, not on `quarto-preview`/`quarto-p2p`). Deps: `iroh` +(default features), `iroh-tickets`, `subtle`, `rand`, `tokio` — with +**explicit `io-util` + `net` features**: `io::join`/`copy_bidirectional` are +`io-util`-gated, the workspace default set (`rt-multi-thread`, `macros`) +lacks it, and today it only unifies on via quarto-hub's `full` — `tracing`, +`thiserror`. (Lockfile check: `subtle`, `rand`, and the Phase 1 +WS-test dev-dep `tokio-tungstenite` 0.29 are already in `Cargo.lock` +transitively; the genuinely new tree is iroh + iroh-tickets + postcard.) Host side re-dial-free (Router accept loop); client side owns a +re-dial loop with backoff (existing TCP conns die on connection loss; new ones +use the fresh connection; the SPA's health supervisor papers over the gap). +Client registers the ticket's `EndpointAddr` in a `MemoryLookup` +(`iroh/src/address_lookup/memory.rs:75`) so re-dials re-resolve without n0 +infra. + +--- + +# Phases + +Sequencing: **Gate 0 first — nothing else starts until it returns "go."** +Then 0 → 1 → 2 → 3 are strictly ordered. **Phase 4 (`--ui editor`) is +independent of iroh entirely** and can proceed in parallel with Phase 0/1 +once the gate passes — it waits for the gate like everything else, because a +no-go kills the epic and P4 only exists here in service of the share story. +Phase 5 is a spike, gated on 3 + 4. + +## Gate 0 — Feasibility spike (go/no-go) + +**Nothing below this section starts until this gate returns "go."** The plan +commits to a specific architecture (dumb token-authenticated TCP proxy over +iroh QUIC) and a heavy new dependency before any of it has run end-to-end. +This gate front-loads the bets that would kill or reshape the plan, at +throwaway-spike cost, before we pay for Phase 1's full TDD surface and five +phases of implementation. + +**Mechanics.** Time-box: ~2 working days. Spike code lives on the gate +strand's branch (`braid/bd-l4j4ky8k-live-share-feasibility-gate`) and is +**throwaway — it is never merged**; what lands on the integration line is +this section's findings, the measurements, and the recorded decision. +**TDD exemption:** this is an investigation, not a feature — the repo's +TDD rule resumes at Phase 1, which re-implements from scratch under tests +(salvage knowledge, not code). Spike shortcuts allowed: `unwrap()`s, no +re-dial loop, no graceful shutdown, a debug-printed `EndpointAddr` + hex +token instead of the ticket format. Shortcuts NOT allowed: a real +`q2 preview` server as the tunnel target, a real browser as the client, +the real n0 relay for the cross-network leg, and the 32-byte token prefix +on each stream (it doubles as iroh's write-first requirement, so skipping +it would make the spike unrepresentative). + +**Feasibility questions (spike):** + +- [x] **Q1 — Does the SPA work through the tunnel at all?** One machine: + `q2 preview` on a fixture project, hand-rolled tunnel host in front of + it, hand-rolled tunnel client on another port, browser pointed at the + client port. Pass: SPA boots, `/health` returns the index doc id, the + document renders (assets + WASM fetched through the tunnel), and a + host-side file edit propagates live to the browser — i.e. the splice + carries both plain HTTP and a long-lived WebSocket. Run in **Chrome + and Firefox** — browser-specific WS behavior has bitten this SPA + before (`2026-06-11-firefox-ws-peer-timeout-fix.md`), and a + browser-specific failure is cheaper to learn here than in Phase 2. + Optional (non-blocking): point a second tunnel client + browser at + the same host as a multi-guest sanity check — v1 scope is N + concurrent guests (see the architecture scope note). +- [x] **Q2 — Does it survive a real session?** Keep the Q1 session open + ≥10 minutes including ≥2 minutes fully idle, then edit again. Pass: + the edit still propagates. This checks the QUIC keep-alive vs. + browser-connection-pooling interaction (`endpoint/quic.rs:155-161`, + 5 s keep-alive vs. 30 s idle) in reality, not just in source. Run + the idle leg in Safari as well (free on this machine) — + idle-connection handling is exactly where browsers differ. + Opportunistic observation, not pass/fail: close the host laptop's + lid mid-session and note what the guest sees on wake — sleep/wake is + the most common real-world connection death and the observation + informs Phase 1's re-dial design (the reconnect story itself is + Phase 1 scope). +- [x] **Q3 — Does the relay path work and feel usable?** Host and guest on + different networks, real n0 relay. Logistics: network namespaces are + Linux-only and the dev host is macOS, so this leg is **two physical + machines** (e.g. one on a phone hotspot); a headless cloud VM guest + cannot measure browser time-to-first-render. (If a test network + blocks UDP entirely, iroh falls back to relay-over-TCP-443 — that is + the designed worst case, not a failure to debug.) Pass: join + succeeds. Record **three numbers**, not one: (a) time-to-first-render + on the guest, (b) bytes transferred + effective throughput for the + asset boot, (c) edit→propagation latency once the session is warm — + (c) is the steady-state "feels usable" number (sync frames are + small, so it should be ~relay RTT; confirm). **Known confounder for + (a):** the preview server serves everything uncompressed (verified: + no CompressionLayer anywhere in the preview/hub stack — + quarto-hub's tower-http features are only `trace`/`cors`/ + `set-header`), so the ~38 MB WASM travels at full size; (b) exists + to make a breach attributable to tunnel vs. payload. Soft threshold: + < 30 s time-to-first-render on a residential-class connection; + worse ⇒ conditional go, mitigation ladder in order of cost: + (1) HTTP compression on the preview server + (`tower_http::CompressionLayer` or a precompressed `.wasm.br` — + wasm compresses ~3–5×, no version-skew cost), (2) guest-side asset + serving (risk #5) promoted from follow-up into Phase 1 scope. +- [x] **Q4 — Is the dependency weight acceptable?** Measure and record + here: `q2` release binary size and clean-build wall time, before vs. + after the iroh dep tree. Methodology (so Phase 0's re-measure + compares like with like): release build of `--bin q2`, `cargo clean` + before each timed run, baseline and after measured back-to-back on + the same machine — the bd-xvdop controlled-measurement style — and + record the exact iroh/iroh-tickets versions measured. Soft + thresholds: ≤ 15 MB binary growth and ≤ 25 % clean-build slowdown; + worse ⇒ conditional go, feature-gating decision required before + Phase 0 starts. + +**Static checks (no spike code needed, but gate-blocking):** + +- [x] Licensing: iroh is MIT OR Apache-2.0 + one BSD-3 notice + (`../iroh/iroh/LICENSE-BSD3`) to carry in attribution — confirm + nothing else in the transitive tree (noq, netwatch, portmapper, + hickory, reqwest) is problematic. This repo has + **no cargo-deny infrastructure** (no `deny.toml`, no CI step), so + this is a one-off inventory on the spike branch (ad-hoc + `cargo deny check licenses` with a scratch config, or + `cargo license`), not an existing gate to pass +- [x] n0 usage policy: `--share` defaults to n0's hosted relays and pkarr + publishing. Licensing covers the code; this covers the **service**: + confirm n0's terms for third-party production use of the public + relay/DNS infrastructure (fair-use expectations, rate limits, any + "run your own relay for shipped products" guidance). Same class of + due diligence as the license inventory — cheap to check now, + expensive to discover post-ship +- [x] Windows: the spike crate compiles for Windows (iroh supports it; + watch `netwatch`/`portmapper`). **Test-suite CI + has no Windows leg** (`.github/workflows/test-suite.yml` matrix is + ubuntu + macos; only `release.yml` builds Windows). A manual check + on a real Windows machine (`cargo xtask test` per + `claude-notes/instructions/windows-dev.md`) works if one is handy; + **do not** burn spike time on + `cargo check --target x86_64-pc-windows-msvc` from macOS — it dies + in ring's C/asm build script (no MSVC-targeting toolchain) before + saying anything about iroh. The reliable cheap signal is a + **throwaway `workflow_dispatch` GH Actions job** on the spike branch + running `cargo check` on `windows-latest` (release.yml already + proves those runners build this tree); it is not a PR-CI signal +- [x] WASM closure unaffected: `cargo tree -i iroh` from + `wasm-quarto-hub-client` must fail + +**Decision (the actual gate):** + +- [x] Findings recorded above — exact invocations, output snippets, and + numbers; the repo's end-to-end evidence policy applies to the spike + too ("output inspected" notes, no success-by-absence-of-errors) + *(see "Gate 0 findings (spike executed 2026-08-04)" below)* +- [x] Verdict posted as a `braid comment` on the epic (bd-yyoyvx91); + **user sign-off required** — go / conditional go / no-go is the + user's call, informed by this data *(findings posted 2026-08-04; + **user signed off: GO**, 2026-08-04 — recorded on the epic; gate + strand bd-l4j4ky8k closed; Phase 0 and Phase 4 unblock)* + +| Verdict | Meaning | +|---|---| +| **Go** | Q1–Q4 pass, static checks green → Phase 0 unblocks | +| **Conditional go** | Q3 or Q4 breached a soft threshold → user picks the mitigation (feature-gate iroh; add HTTP compression to the preview server; promote guest-side asset serving; LAN-only v1 scope) and this plan is amended before Phase 0 starts | +| **No-go** | Q1/Q2 fail unfixably (the splice can't carry the SPA), or licensing/Windows are hard blockers → the epic closes, or the architecture section is redone from the rejected-alternatives list (guest-side SPA + samod-native transport is the fallback to re-evaluate); Phases 0–5 do not start | + +### Gate 0 findings (spike executed 2026-08-04) + +**Spike artifacts.** Branch `braid/bd-l4j4ky8k-live-share-feasibility-gate` +(throwaway — never merged), commits `e80cebf8` (tunnel host+client crate +`crates/q2-p2p-spike`) and `9067b008` (Q4 wiring + Windows workflow). +Dependencies measured: **iroh 1.0.3 + iroh-tickets 1.0.0 from crates.io** +(spike client's `--relay-only` uses a custom `PathSelector` behind iroh's +`unstable-custom-transports` feature — zero extra deps). Browser legs driven +by Playwright (Chromium 1223, Firefox 1522, WebKit 2287) with a scratchpad +driver script; one non-obvious detail for Phase 2/3 e2e work: **the preview +SPA renders the document inside an iframe**, so text assertions must scan +`page.frames()`, not the top page. + +**Q1 — PASS (Chromium + Firefox, two concurrent guests).** +Invocations: `q2 preview --no-browser` (port 49583, 2-page fixture +project) ← `spike-tunnel-host 127.0.0.1:49583` ← two +`spike-tunnel-client 9280|9281` ← browsers at +`http://127.0.0.1:928x/?page=index.qmd`. +- `curl http://127.0.0.1:9280/health` through the tunnel returned the + **identical** payload to direct, including + `"index_document_id":"4ZLBFnLKivaVWXc9HdF2SCACTn2U"`. +- SPA booted and rendered in both browsers; **~47.5 MB fetched through the + tunnel per guest** (uncompressed, 38.4 MB of it the WASM — the plan's + no-compression confounder confirmed); first render 1.7 s (Chromium) / + 1.9 s (Firefox) on the direct path. +- Host-side `sed` edit (`MARKER-0`→`MARKER-1`) propagated live to **both + guests simultaneously** in **554 ms (Chromium) / 587 ms (Firefox)** — + multi-guest sanity check passed in the same run. Before/after screenshots + inspected: rendered document shows the new marker text in both browsers. +- Wrong-token check: client with a zeroed token → host logs + `BAD TOKEN - dropping stream`, curl fails, zero bytes reach the target. +- Splice carries HTTP + the long-lived `/ws` WebSocket by construction + (sync frames are what propagated the edits). + +**Q2 — PASS (12 min session, fully idle, Chromium + WebKit).** +Fresh sessions on both tunnel clients, then **720 s fully idle** (only the +SPA's own background polling), then another host edit +(`MARKER-1`→`MARKER-2`): propagated in **1.07 s (Chromium) / 1.18 s +(WebKit)**. Post-soak screenshots inspected — rendered `MARKER-2` visible. +The QUIC keep-alive (5 s) vs browser-connection-pooling (30 s idle timeout) +interaction is a non-issue in practice. Caveats: WebKit (Playwright) stands +in for Safari — same engine, not the Safari app; the lid-close observation +was not performed (no way to close the lid programmatically) — fold it into +the real cross-network session below. + +**Q3 — PASS. Two measurements: (i) single-machine relay-pinned +approximation, (ii) real cross-network leg via a GH Actions guest (below).** +The spike client's `--relay-only` flag pins path selection to relay paths +(verified: **zero** DIRECT selections in the client log for the whole leg; +selected path stayed `euc1-1.relay.n0.iroh.link`, rtt ~31 ms warm). Fresh +Chromium boot through the relay-pinned tunnel: +- (a) time-to-first-render: **4.07 s** (soft threshold was < 30 s) +- (b) bytes: ~47.5 MB → ≥ **11.7 MB/s** effective through the real n0 relay + (lower bound; includes render time) +- (c) edit→propagation warm: **1.00 s** (vs ~0.55 s direct — consistent with + "+relay RTT") +Honest limitation: both endpoints shared this machine's (fast) connection, +so (a)/(b) are not a residential-guest measurement — but the traffic did +transit the real n0 relay, so protocol behavior and rate-limiting posture +are exercised. Also observed (informs Phase 1 status messaging): on loopback +the first selected path after connect is RELAY, upgrading to DIRECT within +~3–6 s — initial connect in 162 ms. + +**Q3 cross-network leg — executed and PASSED (2026-08-04, user-approved +push).** The plan assumed a cloud VM guest "cannot measure browser +time-to-first-render" — true for eyeballs, false for a Playwright driver +measuring it in-process, so the two-machine leg was automated: live host on +the dev machine (`q2 preview` + `spike-tunnel-host`, plus a loop bumping a +numbered marker every 20 s with ms timestamps), guest = `ubuntu-latest` +GH runner (Azure network) that builds the spike client, joins via the +ticket (passed through ephemeral repo secrets, deleted after the run), +and boots headless Chromium through the tunnel. Workflow +`spike-q3-guest.yml`, run 30897199010; boot + final screenshots for both +legs downloaded from the artifact and inspected (rendered document at the +last observed marker). Results: +- connect: **0.42 s** cross-network. **Both legs — default and + relay-pinned — stayed on the relay for their whole run**: hole-punching + Azure↔residential NAT never yielded a selected direct path, so this + measured exactly the relay-fallback scenario Q3 exists for. + Guest→relay rtt ~130 ms (`euc1-1` from Azure); relays probe at + 200/OK in 0.08–0.5 s from the runner. +- (a) time-to-first-render: **13.2 s (default) / 13.3 s (relay-pinned)** — + soft threshold < 30 s, PASS. +- (b) ~47.5 MB per boot → **~3.7 MB/s sustained through the relay** + (uncompressed WASM dominates; the HTTP-compression mitigation would cut + the payload ~3–5×). +- (c) edit→observed propagation over 6 marker bumps: **0.81–2.34 s, + median ~1.1 s** (includes the driver's 150 ms poll grain and host↔runner + NTP clock skew). +- Caveats: runner egress is datacenter-class, not residential; the + Safari-app and lid-close observations still need a human-driven session. +- Reliability note worth keeping: the **first** guest run (30896594167's + sibling, run 30896593541) failed at `ep.connect` with a bare timeout — + the host saw no inbound attempt — and was unreproducible 8 minutes later + (identical code + ticket connected in 0.42 s). A transient + n0 discovery/relay hiccup of exactly the class Phase 1's re-dial/backoff + and Phase 3's error UX must absorb (the spike client retries nothing by + design). + +**Q4 — dependency weight (controlled, bd-xvdop style: `cargo clean` before +each timed run, back-to-back on the same machine, release `--bin q2`, +iroh 1.0.3 wired reachably into the binary via an env-gated hook).** +- baseline (branch point, no iroh): **94.9 s** wall, binary **81,814,464 B + (78.0 MiB)** +- after (iroh dep tree linked): **110.6 s** wall, binary **95,602,544 B + (91.2 MiB)** +- deltas: **+13.79 MB binary** (threshold ≤ 15 MB — within) and **+16.6 % + clean-build wall time** (threshold ≤ 25 % — within) → **PASS, no + feature-gating decision forced** +- reachability verified end-to-end: the measured release binary itself ran + the tunnel host (`Q2_SPIKE_TUNNEL_TARGET=127.0.0.1:49583 + ./target/release/q2` printed a live TICKET/TOKEN) — the delta is not a + dead-code artifact + +**Static checks.** +- **Licensing — PASS.** One-off inventory of the 141 packages the iroh tree + adds to `Cargo.lock` (`cargo tree --format "{p}|{l}"` + `cargo metadata` + for target-specific deps): all permissive. MIT/Apache-2.0 dual dominates; + BSD-3-Clause: `curve25519-dalek`, `ed25519-dalek` (attribution notices to + carry, matching iroh's own `LICENSE-BSD3`); BSD-2-Clause: `arrayref`, + `spez`; **one MPL-2.0: `attohttpc`** (via the portmapper stack; file-level + weak copyleft — fine as an unmodified linked dep); 3× Unlicense + (`async_io_stream`, `pharos`, `ws_stream_wasm` — wasm-target-only); + 1× Zlib-or; no GPL/LGPL/AGPL/SSPL anywhere. +- **n0 usage policy — PASS with eyes open.** No dedicated ToS for the preset + infra exists (itself a finding). DNS-discovery docs explicitly say + *"You're more than welcome to run production systems using the public + relays if you find performance acceptable"* — but relays are rate-limited + (numbers unpublished, *"can change at any time"*, iroh 1.0 post), carry + *"no guaranteed uptime"*, and the add-a-relay doc says *"production + deployments should run their own."* Iroh Services ToS (May 2025) reserves + unilateral termination, caps liability at US$50. Paid offering exists + (Iroh Services; dedicated relays ~$197/mo, free tier 10 concurrent + endpoints). Public-relay sunsets are per-protocol-version on an announced + schedule (v1.0 relays "until End of Life"). Verdict: ephemeral preview + sharing is well inside tolerated use — relays are handshake/fallback + paths, not the primary data path — but **make relay/DNS endpoints + user-configurable** (follow-up strand at epic close) and track the + per-version relay sunset schedule at iroh upgrades. +- **Windows — PASS.** Throwaway workflow on the spike branch + (`.github/workflows/spike-windows-check.yml`) pushed with user approval + as `spike/bd-l4j4ky8k-live-share-feasibility-gate` and run on + `windows-latest`: `cargo check -p q2-p2p-spike -p quarto` (the iroh + tunnel crate + the fully wired q2 closure) succeeded in 5m36s — GH run + 30894960520, 2026-08-04. Gotcha for posterity: `workflow_dispatch` only + registers from the default branch (404 on dispatch), so the workflow + fires `on: push` to `spike/**` instead. +- **WASM closure — PASS.** From `crates/wasm-quarto-hub-client` (spike + branch, iroh in the workspace lockfile): + `cargo tree -i iroh` → `error: package ID specification 'iroh' did not + match any packages`. +- Bonus datapoint: the spike's bare `EndpointTicket` printed at 151 chars + (+ 64 hex token chars) — consistent with the plan's 173–235-char estimate + for the combined `q2preview` ticket. + +**Runbook — optional residential-class Q3 re-run (user + one extra +machine/network; the cross-network leg itself already passed via the GH +Actions guest above — this remains only if a residential-uplink +time-to-first-render number is wanted):** +1. On the host machine (this repo, spike branch worktree): + `cargo run --bin q2 -- preview --no-browser` (note the port), + then `./target/debug/spike-tunnel-host 127.0.0.1:` (from + `.worktrees/bd-l4j4ky8k-live-share-feasibility-gate`); copy TICKET + + TOKEN. +2. On a second physical machine on a different network (e.g. phone + hotspot), build the spike client (`cargo build -p q2-p2p-spike` on the + spike branch) and run + `spike-tunnel-client 9280 --relay-only` (relay-pinned; + drop `--relay-only` for a hole-punching run — the client logs which path + is selected). +3. Browser at `http://127.0.0.1:9280/?page=index.qmd`; stopwatch + time-to-first-render; edit a file host-side and eyeball propagation. + Optionally close the host's lid mid-session and note what the guest sees + on wake (informs Phase 1 re-dial UX). + +**Verdict — GO, signed off by the user 2026-08-04** (all four questions +and all four static checks passed; no soft threshold breached). Gate +strand bd-l4j4ky8k closed; Phase 0 (bd-9gam4jqe) and Phase 4 +(bd-jt1etjbn) unblock. Optional extras left open: a residential-class Q3 +re-run (runbook above) and a human-driven session for the +Safari-app/lid-close observations. Housekeeping done: remote `spike/…` +branch deleted 2026-08-04 (throwaway; existed to run the Windows + +cross-network CI legs), ephemeral SPIKE_* secrets deleted, session token +dead with the host process; the local spike worktree +(`.worktrees/bd-l4j4ky8k-live-share-feasibility-gate`) is kept for +reference until Phase 1 re-implements under tests — its code is still +never to merge. + +## Phase 0 — Scaffold + +No behavior; keep it short. Dependency due-diligence (size/build-time +measurement, licensing, Windows, WASM closure) lives in Gate 0 — Phase 0 +only re-establishes those facts on the real crate wiring, since the gate +proved them on a throwaway branch that never merges. + +- [x] Add `crates/quarto-p2p` (lib) to the workspace; deps as above; empty + public API stubs behind `todo!()` are fine at this point + *(done 2026-08-04: `PreviewShareTicket`, `TunnelHost`/`TunnelHostHandle`, + `TunnelClient`/`TunnelClientHandle`, `TunnelStatus`, `TunnelError`; + all method bodies `todo!("Phase 1 (bd-v8mwzpmi)")`; deps iroh 1.0.3 + (default features), iroh-tickets 1.0, subtle 2, rand 0.9, + tokio +io-util+net, tracing, thiserror; `[workspace.dependencies.quarto-p2p]` + entry added for Phase 2's consumer)* +- [x] `cargo build --workspace` green; confirm `cargo xtask verify + --skip-hub-build` unaffected *(done 2026-08-04: workspace build + green; `cargo xtask verify --skip-hub-build` → "All verification + steps passed!", output inspected)* +- [ ] Re-confirm the Gate 0 static checks on the real scaffold: + `cargo tree -i iroh` from `wasm-quarto-hub-client` fails; a Windows + build compiles the crate (manual or release-workflow leg — + test-suite CI has no Windows matrix entry); the dep set matches what + Gate 0 measured (if it drifted — e.g. feature changes — re-measure + Q4 and update the gate section's numbers) + *(2026-08-04 status: **WASM closure PASS** — `cargo tree -i iroh` + from `crates/wasm-quarto-hub-client` → "package ID specification + 'iroh' did not match any packages"; **dep set PASS** — Cargo.lock + additions are name+version-identical to the gate branch's lockfile + (141 external packages; iroh 1.0.3, iroh-tickets 1.0.0; only + symmetric diff is `quarto-p2p` vs `q2-p2p-spike`), so no Q4 + re-measure; **Windows PENDING** — needs a push (user approval) to + run a windows-latest `cargo check -p quarto-p2p` leg; the gate + proved the identical dep set compiles there on 2026-08-04, run + 30894960520, so only the ~90-line stub crate itself is unproven)* + +## Phase 1 — `quarto-p2p` core (TDD) + +Tests live in `crates/quarto-p2p/tests/integration/` per the integration-test +layout rule (single `main.rs` binary). All tests hermetic: `presets::Minimal`, +`RelayMode::Disabled`, explicit loopback `TransportAddr::Ip` addrs — **no n0 +infrastructure in CI**. + +**Test specs (write these first, watch them fail):** + +- [ ] `ticket::roundtrip` — ticket with relay + ip addrs + token → + `to_string()` (starts with `q2preview`) → `parse()` → equal +- [ ] `ticket::rejects_garbage_and_foreign_kinds` — empty string, random + base32, a bare iroh `EndpointTicket` string (`endpoint…`) all fail with + a typed error +- [ ] `ticket::debug_redacts_token` — `format!("{ticket:?}")` does not contain + the token bytes/hex +- [ ] `tunnel::http_roundtrip_loopback` — tiny axum server as target; host + endpoint + `TunnelHost::spawn`; client endpoint + `TunnelClient::bind`; + raw HTTP/1.1 GET through the client's local port returns the body; + repeat over ≥8 **concurrent** connections (concurrent QUIC streams) +- [ ] `tunnel::websocket_frames_survive` — target is an axum `/ws` echo; + `tokio-tungstenite` client through the local port; upgrade + a few + frames round-trip (proves the splice handles long-lived duplex traffic) +- [ ] `tunnel::wrong_token_rejected` — stream with a wrong/short token is + reset; the target server sees **zero** TCP connections (count accepts) +- [ ] `tunnel::client_redials_after_connection_loss` — drop the host-side + connection; next local TCP conn succeeds after client re-dial +- [ ] `tunnel::half_close_propagates` — guest-side TCP write-half shutdown + reaches the target as read-EOF (and the reverse direction), while the + other direction keeps flowing; guards the splice's EOF ↔ + `SendStream::finish()` mapping, which `websocket_frames_survive`'s + symmetric traffic does not exercise +- [ ] `tunnel::clean_shutdown` — `shutdown()` on both handles completes + without hangs and unbinds the local port + +**Implementation:** + +- [ ] `ticket.rs` — struct + `iroh_tickets::Ticket` impl + `FromStr`/`Display` +- [ ] `host.rs` — `Endpoint` (preset injectable for tests) + `Router` with a + `ProtocolHandler` whose `accept()` loops on `accept_bi()`, spawning per + stream: `read_exact` 32-byte token under a 10 s timeout → constant-time + compare → `TcpStream::connect(target)` → + `copy_bidirectional(&mut tokio::io::join(recv, send), &mut tcp)`; + log `remote_id().fmt_short()` per connection. **Half-close:** + `copy_bidirectional` propagates read-EOF as `poll_shutdown` on the + opposite writer; via `tokio::io::join` that must land as + `SendStream::finish()` (QUIC FIN), and a stream FIN must become TCP + write-shutdown — verify with `tunnel::half_close_propagates` rather + than assuming the adapter chain does it +- [ ] **QUIC keep-alive vs. browser connection pooling:** browsers hold idle + pooled HTTP/1.1 connections open for minutes; if the iroh connection's + idle timeout fires in between, the next request on a pooled TCP conn + fails before the client re-dials. iroh's defaults already cover this — + keep-alive 5 s vs. 30 s connection idle timeout + (`iroh/src/endpoint/quic.rs:155-161`, `socket.rs:109`; connection idle + is the noq default 30 s) — so this item is a verifying test, not new + config. If we ever do override: the type is `QuicTransportConfig` / + `QuicTransportConfigBuilder` set via `Builder::transport_config` + (`endpoint.rs:669`) — **not** `TransportConfig`, which in iroh v1 is an + unrelated internal socket-transport enum. Do not rely on the SPA's + health polling to keep the tunnel warm +- [ ] `client.rs` — endpoint + `MemoryLookup` seeded from the ticket; + `TcpListener` accept loop; per conn: `open_bi()` on the current + connection (re-dial with expo backoff on failure), write token, splice; + status watch channel for CLI messaging +- [ ] Shutdown plumbing: `router.shutdown().await?` on the host (it closes + the endpoint itself; handle the returned `JoinError`); abort accept + loop + close endpoint on the client + +## Phase 2 — `q2 preview --share` (host) + +**Tests first:** + +- [ ] CLI: `--share` parses; `--share --join x` rejected. **New tests, not an + extension** — `crates/quarto` has no clap parse tests today + (`preview.rs:625-647` are boot-URL formatting tests; the one existing + exclusion is a runtime bail at `preview.rs:70`), so build the small + `try_parse_from` harness this plan's conflict matrices need +- [ ] `quarto-preview` unit: share glue produces a ticket whose tunnel target + is `127.0.0.1:{config.port}` (the port is resolved CLI-side before the + server starts, `preview.rs:114-117` — `on_ready` does *not* carry it); + ticket line printed via an injected writer/callback — do not scrape + stdout + +**Implementation:** + +- [ ] `PreviewArgs::share` + clap flag (`crates/quarto/src/main.rs` Preview + variant) → `PreviewConfig::share` (`crates/quarto-preview/src/lib.rs`) +- [ ] `quarto-preview` → `quarto-p2p` dep; when sharing: generate token, bind + endpoint (`presets::N0`), `timeout(Duration::from_secs(10), online())` + (on timeout: proceed, warn "relay unreachable — direct/LAN connections + only"), `TunnelHost::spawn` targeting `config.port`. **Print timing:** + the `on_ready` callback receives only `Arc` and fires + *before* the listener binds (`server.rs:1915` vs. `:1933`) — so a + ticket printed there precedes accept. That is the same property as the + existing CLI boot-URL print (`preview.rs:136-141`) and is acceptable + (a too-fast guest just retries via its health supervisor), but the + print does not need `on_ready` at all — the ticket's only inputs + (`config.port`, token, endpoint addr) exist before the server starts: + + ``` + Sharing this preview session (end-to-end encrypted via iroh). + Anyone with this string can VIEW the project and RE-RUN its code: + [and EDIT files on this machine — only if --allow-edit] + + q2 preview --join q2preview<...> + ``` + + The ticket string will be long — **measured** with a + scratch impl against crates.io iroh-base/iroh-tickets 1.0 (postcard + of `{id, addrs, token}`, KIND `q2preview`, real n0 relay hostname): + 173 chars relay-only, 235 chars typical (relay + 2×IPv4 + IPv6), + 336 chars with 5×IPv6 — i.e. 3–5 wrapped lines at 80 columns. The + same experiment validated that the planned wire shape round-trips + through `encode_string`/`decode_string` exactly as the trait docs + promise. Print the `q2 preview --join …` line on its own line with + nothing after it, so a triple-click / drag copy survives terminal + wrapping; the end-to-end check below must include copy-pasting the + wrapped line from a real terminal +- [ ] Ctrl-C: tunnel shutdown joined into the existing graceful-shutdown path + (before the `TempDir` drop) +- [ ] **End-to-end (mandatory, record invocation + output here):** two + terminals on one machine — host `--share` in a fixture project, guest + `--join`; `curl http://127.0.0.1:/health` shows the host's + `index_document_id`; browser on the guest port renders the document; + live edit on host propagates + +## Phase 3 — `q2 preview --join ` (guest) + +**Tests first:** + +- [ ] CLI conflict matrix: `--join` × each of {path, `--share`, + `--no-project`, `--allow-edit`, `--ui editor`, `--data-dir`, + `--preview-dir`} rejected; × {`--port`, `--no-browser`, `--host`} accepted +- [ ] **The money test** (integration, `crates/quarto-preview` or `quarto-p2p` + with a dev-dep on `quarto-hub`): start a real preview hub in-process on + a fixture project (`run_server_with`), `TunnelHost` in front of it, + `TunnelClient` on a random port (all hermetic-iroh); then through the + guest port: (a) `GET /health` → 200 with the host's + `index_document_id`; (b) `repo.dial_websocket("ws://127.0.0.1:{guest}/ws")` + with an in-memory samod client → load `IndexDocument` → files map + matches the fixture. This proves automerge-sync-over-tunnel without a + browser. Patterns: `dial_websocket` per + `crates/quarto-hub-provider/tests/integration/relay_sync.rs:54` (works + here because preview's `/ws` takes no credentials — **verified**: + preview sets `auth_config: None`, + `quarto-preview/src/lib.rs:407-410`, and `ws_handler` skips + credential *and* Origin checks entirely when no auth config is set, + `quarto-hub/src/server.rs:1607,1642-1644`); in-memory repo + + `IndexDocument::load` per `join.rs:30-56` — but note `join.rs` itself + dials via `repo.dial(BackoffConfig, BearerDialer)` because + `dial_websocket` cannot set auth headers (that distinction matters + again in Phase 5) + +**Implementation:** + +- [ ] `--join ` arg; guest path in `commands/preview.rs::run` that + bypasses project resolution/TempDir/hub entirely: parse ticket, + `TunnelClient::bind(("127.0.0.1"|--host, --port or probed))`, print URL +- [ ] Browser-open readiness = first successful `GET /health` **through the + tunnel** (extend `wait_until_accepting`, `preview.rs:294-327` — a local + TCP accept alone would lie when the tunnel is dead) +- [ ] Status messaging from the client watch channel: "connected via + ", "reconnecting…". API pinned: + `Connection::paths()` returns a `PathList` whose `Path` entries + expose `is_selected()` / `is_relay()` / `is_ip()` / `rtt()` + (`../iroh/iroh/src/socket/remote_map/remote_state/path_watcher.rs:446-494`); + `paths_stream()` / `path_events()` give live snapshots for the + watch channel (`endpoint/connection.rs:1144-1176`). The selected + path's `is_relay()` is the direct-vs-relay discriminator. + (`Endpoint::remote_info(EndpointId)` also exists, `endpoint.rs:1623`, + but the per-connection API is the right one here) +- [ ] Ctrl-C teardown; clear error UX for: malformed ticket, host unreachable, + token rejected (host rotated/session ended) +- [ ] **End-to-end (mandatory, record here):** cross-machine host/guest run + with the real n0 relay path (netns is Linux-only — same logistics as + Gate 0 Q3: two physical machines); inspect rendered output in the + guest browser; note "verified in browser". Join **two guests + concurrently** at least once (v1 scope is N guests — architecture + scope note) + +## Phase 4 — `q2 preview --ui editor` (independent track) + +Serve the **full hub-client editor** from the preview server instead of the +read-only SPA. Zero hub-client *source* changes expected — we reuse the +`#/share/` route with a relative `server` (hub-client resolves relative sync +URLs against the page origin: `hub-client/src/utils/routing.ts:51-57`; share +params parsed at `routing.ts:226-239`, all three of `server`/`file`/`name` +required: `App.tsx:418-423`). + +**Tests first:** + +- [ ] CLI: `--ui viewer` / `--ui editor` parse (clap `ValueEnum`, default + `viewer`); an unknown value (`--ui monaco`) is rejected with the list + of valid values +- [ ] Rust unit: `--ui editor` boot URL builder emits + `http://{host}:{port}/#/share/{indexDocId}?server=%2Fws&file={rel}&name={project}` + (doc id **without** the `automerge:` prefix — `routing.ts:420`; `file` + falls back to the first `.qmd` when no initial page was resolved; note + params ride the **hash fragment**, not the URL query) +- [ ] Rust unit: `--ui editor` leaves the write policy alone: with + `--allow-edit` → `DiskWritePolicy::WriteBack`; without → + `DiskWritePolicy::ReadOnly` **and** the ephemeral-session note is + emitted ("session edits are ephemeral — pass `--allow-edit` to + persist to disk") — assert via an injected writer/callback, not + stdout scraping (same style as Phase 2's ticket-line test) +- [ ] Build-time: embed-dir placeholder fallback works on a tree without the + editor dist (mirror the existing `QUARTO_PREVIEW_EMBED_DIR` placeholder + test story, `crates/quarto-preview/build.rs:19-43,60-107`) +- [ ] `cd hub-client && npm run build:all` still green (CRITICAL per + CLAUDE.md); new embed build produces a servable dist + +**Implementation:** + +- [ ] hub-client: `build:preview-embed` npm script — no `VITE_GOOGLE_CLIENT_ID` + (auth UI off: `App.tsx:104`), `VITE_DEFAULT_SYNC_SERVER=/ws`, outDir + `dist-preview-embed/` via `vite build --outDir`. Note: hub-client has + **no** alternate-config/outDir precedent to copy — the only prior art is + env-var-at-build-time (`build:local-prod`, + `scripts/build-local-prod.sh:11`); the script must keep the 4 HTML + entry points (main, debug, q2-debug, q2-preview) and run after the + `build:wasm` + `build:sandboxed` pre-steps. **hub-client change ⇒ + two-commit changelog rule** +- [ ] xtask: `build-hub-client-embed` (sibling of `build_q2_preview_spa.rs`) +- [ ] `quarto-preview/build.rs`: second build-script-**emitted** env + (`cargo:rustc-env=QUARTO_HUB_CLIENT_EMBED_DIR=…` — mirroring how + `QUARTO_PREVIEW_EMBED_DIR` actually works: build.rs emits it at :30-33, + nothing reads it from the environment) + `include_dir!` + placeholder + fallback; runtime: `--ui editor` flips which dir `spa_handler` + (`lib.rs:490-509`) serves +- [ ] `--ui` flag (clap `ValueEnum` `PreviewUi { Viewer, Editor }`, default + `Viewer`) → boot URL in share-route form when `editor`. **Structural + change required:** today the CLI builds *and prints* the boot URL and + captures it in the browser-open task (`preview.rs:136-141`, :158-180) + before `quarto_preview::run` is even called (:224), while the index doc + id only exists server-side (`ctx.index().document_id()`, + `quarto-hub/src/index.rs:138` — bare id; the share route wants it + bare), reachable in `on_ready`. So move boot-URL construction into the + library, or channel the doc id from `on_ready` back to the CLI's + print + browser-open path — "available in `on_ready`" is not enough by + itself. No write-policy coupling — without `--allow-edit`, emit the + ephemeral-session note instead of flipping `DiskWritePolicy` +- [ ] Dedupe the shared `wasm_quarto_hub_client_bg.wasm` across the two + embeds. **Decided from measured numbers:** the artifact + is 38,371,765 bytes and **byte-identical** in both dists (sha256 + `a075c962…`), and Vite's content hashing even gives it the same + hashed filename in both (`wasm_quarto_hub_client_bg-B4wtBy8i.wasm`) + since the hash is content-derived. hub-client dist ≈ 67 MB, + q2-preview-spa dist ≈ 45 MB — a naive double-embed adds ~67 MB to + `q2`, ~38 MB of it pure duplication. So: serve the `.wasm` from a + single shared embed; the identical content-hashed filename makes + "strip from one dist, route both asset paths to the shared copy" the + natural mechanism (exact design in this phase). Still record the + final binary delta after dedupe +- [ ] Known warts to document in `--help` + here: hub-client persists a + ProjectEntry + IndexedDB automerge cache per ephemeral session (stale + entries accumulate across preview restarts — follow-up strand); + `--share --ui editor` means the *host* picks the UI for all guests +- [ ] End-to-end (mandatory): real browser session — editor loads, file + sidebar shows the project, Monaco edit persists to host disk (verify + file content on disk), preview pane updates + +## Phase 5 — Spike: `--join https://quarto-hub.com/#/share/…` + +Design-doc question: can `--join` also target a hosted hub share URL? The +honest answer is "auth is the hard part" — so this phase is a **spike + design +note**, not committed implementation. Deliverable: a +`claude-notes/plans/2026-XX-XX-join-hosted-hub.md` with a go/no-go and a +working prototype behind no flag stability promise. + +Sketch (what research says is feasible): + +- Parse the share URL (precedent: `parse_index_doc_id`, + `crates/quarto/src/commands/provide_hub.rs:63-72`; TS twin + `ts-packages/quarto-hub-mcp/src/share-url.ts`) +- Serve the Phase-4 hub-client embed locally; browser stays same-origin +- The blocker: hub cookie auth is same-origin with an Origin==Host check on + `/ws` (`crates/quarto-hub/src/server.rs:1631-1636`), and browsers can't set + WS headers. So: a local **reverse proxy** that terminates the browser's + same-origin `/ws` and dials `wss://quarto-hub.com/ws` injecting + `Authorization: Bearer ` — the hub already accepts Bearer + on `/ws`, and both the token acquisition (hub-mcp PKCE + keyring, + `ts-packages/quarto-hub-mcp/src/auth/*`) and the header-injecting dial + (`BearerDialer`, `crates/quarto-hub-provider/src/dialer.rs`) have working + precedent. `/auth/actor` (per-project actor id) needs the same proxying +- Open questions for the spike: bearer expiry mid-session (no refresh story on + `/ws` — the hub validates once at upgrade, `server.rs:1595-1601`); whether + q2 grows a browserless Google login flow or shells out to `q2`'s existing + hub auth; identity/presence when several guests proxy through one bearer + +- [ ] Spike + design note + go/no-go + +--- + +# Verification (repo policy) + +- Every phase: `cargo build --workspace`, `cargo nextest run --workspace` + (never through `tail`), `cargo xtask verify --skip-hub-build`; **full + `cargo xtask verify` for Phase 4** (hub-client + embed legs) +- TDD is non-negotiable: each phase's test list lands and **fails** before its + implementation items. **The one exemption is Gate 0**: it is an + investigation spike whose code is never merged; Phase 1 re-implements the + tunnel from scratch, tests-first +- Gate 0's verdict (with the user's sign-off) must be recorded in this file + and as a braid comment on the epic before any other phase's strand moves + to `in_progress` +- End-to-end verification per CLAUDE.md: the checked items marked + *(mandatory, record here)* must capture the exact invocation, an output + snippet, and an explicit "output inspected" note in this file before the + phase's strand closes. Unit tests alone do not close a phase +- No pushes without explicit user approval + +# Risks / open questions + +1. **Dependency weight.** iroh brings noq (quinn fork), netwatch, portmapper, + hickory, reqwest. Compile time + binary size measured in Gate 0 (Q4); + the go/conditional-go criterion is explicit there. + (`fast-apple-datapath` uses private Apple APIs — irrelevant unless we + ever ship via the Mac App Store.) +2. **n0 infrastructure dependency at runtime.** Relays + pkarr are n0-hosted + defaults. Ticket embeds direct+relay addrs, so LAN joins survive an n0 + outage; cross-NAT joins do not. Acceptable for v1; self-hosted relay is the + escape hatch. `IROH_FORCE_STAGING_RELAYS` exists for debugging. +3. **RCE surface honesty.** Sharing = letting guests run the project's code on + the host (that's what preview re-execution *is*). Mitigated by capability + ticket + loud print; not mitigable further without per-peer permissions. +4. **Per-peer permissions don't exist.** One `DiskWritePolicy` + one + process-wide `allowEdit` for all peers. Follow-up strand; likely needs the + samod fork's `AccessPolicy` hook (it exists for exactly this). +5. **Tunnel latency on the relay path.** Asset boot may be slow relayed; + sync frames are small. The preview server serves assets **uncompressed** + (verified: no CompressionLayer in the preview/hub stack; + quarto-hub's tower-http features are `trace`/`cors`/`set-header` only), + so the first lever if boot feels bad is HTTP compression + (`tower_http::CompressionLayer` or precompressed `.wasm.br` — wasm + compresses ~3–5×, no version-skew cost). Only after that: serving SPA + assets from a guest-side embed while proxying only `/health|/ws|/api|/.quarto` + — deliberately deferred (version-skew cost). +6. **Windows** for iroh-dependent tests — Gate 0 checks compile (note: + test-suite CI is ubuntu + macos only; Windows verification is manual per + `claude-notes/instructions/windows-dev.md`, or the `release.yml` windows + leg); if tests misbehave see that file's exclusion guidance. + +# Follow-up strands to file when the epic lands + +- Per-peer edit permissions (AccessPolicy-based) +- Guest-side asset serving (relay-path boot latency) +- `AddrFilter::relay_only()` privacy knob for `--share` +- hub-client ephemeral-session IndexedDB/ProjectEntry cleanup +- samod-native `IrohDialer` for native peers (`q2 provide-hub` over iroh) + +# Braid strands + +- Epic: **bd-yyoyvx91** +- Gate 0: bd-l4j4ky8k · Phase 0: bd-9gam4jqe · Phase 1: bd-v8mwzpmi · + Phase 2: bd-jhvkwosw · Phase 3: bd-6y0p1bne · Phase 4: bd-jt1etjbn · + Phase 5 (spike): bd-ckra329s +- Blocking chain: G0 → P0 → P1 → P2 → P3; P4 blocks only on G0 (parallel + track once the gate passes — the gate *is* the due diligence P4 waits + for); P5 blocks on P3 + P4. `braid ready` should show G0 and nothing + else until it closes (P0 and P4 unblock together when it does). The + skein matches this chain. + +# Reference index + +- Preview CLI + flow: `crates/quarto/src/main.rs:195-243`, + `crates/quarto/src/commands/preview.rs` (`run` :58-225, port probe :229-256, + browser wait :294-327, boot URL :446-471) +- Preview server: `crates/quarto-preview/src/lib.rs` (embed :38, config + routes :443-476, spa_handler :490-509, hub config :360-423, + allow-edit/DiskWritePolicy :414-421) +- Hub seams: `crates/quarto-hub/src/server.rs` (`run_server_with` :1890, + `extend_router`/`on_ready`/`on_file_changed` :1893-1895; `on_ready` fires + :1915 *before* the listener binds :1933, ws auth :1602-1648), + `context.rs` (`acceptor()` :441, repo build :293-316); index doc id via + `ctx.index().document_id()` (`index.rs:138`, bare id — the SPA prepends + `automerge:`, `PreviewApp.tsx:511-513`) +- samod transport seam: samod checkout `samod/src/transport.rs:19-81`, + `acceptor_handle.rs:74-82`; dialer precedent + `crates/quarto-hub-provider/src/dialer.rs`, joiner precedent `join.rs:30-56` +- SPA same-origin assumptions: `q2-preview-spa/src/PreviewApp.tsx` + (deriveWsUrl :539-542, fetchIndexDocId :499-514, allow-edit :523-532, + connect :818-824), `bootController.ts:1-20` +- hub-client share route: `hub-client/src/utils/routing.ts` (:35-57, :99-109, + :226-239, :347-354, :413-431), consumption `App.tsx:409-463` +- iroh (../iroh @ v1.0.3): `iroh/src/endpoint.rs` (builder :952, connect + :1052, accept :1165, online :1358, close :1706), + `endpoint/presets.rs:113`, `protocol.rs` (Router :406-511), + `endpoint/quic.rs` re-exports; `iroh-tickets` 1.0 (`Ticket` trait, + BASE32_NOPAD + postcard); examples `echo.rs`, `echo-no-router.rs`, + `auth-hook.rs`, `transfer.rs` (local-relay dev pattern) +- Prior plans: `2026-05-11-q2-preview-epic.md` (Phase E bullets :311-315 + (unnumbered — no "E.2/E.3" labels exist), out-of-scope + :572-580, Q7 :416-431), `2026-06-10-q2-preview-edit-writeback.md`, + `2026-06-11-firefox-ws-peer-timeout-fix.md` diff --git a/crates/quarto-p2p/Cargo.toml b/crates/quarto-p2p/Cargo.toml new file mode 100644 index 000000000..89ccc05c8 --- /dev/null +++ b/crates/quarto-p2p/Cargo.toml @@ -0,0 +1,28 @@ +# P2P tunnel for `q2 preview --share` / `--join` (live share over iroh). +# Encapsulates ALL iroh usage; quarto-preview and the q2 CLI see only the +# ticket + tunnel types exported from this crate. Native-only: must never +# enter the wasm-quarto-hub-client closure (Gate 0 / Phase 0 static check: +# `cargo tree -i iroh` from that crate must fail). +# Plan: claude-notes/plans/2026-08-03-q2-preview-live-share-iroh.md +[package] +name = "quarto-p2p" +version.workspace = true +authors.workspace = true +edition.workspace = true +license.workspace = true +description = "iroh-based P2P tunnel for q2 preview live share (--share / --join)" + +[dependencies] +iroh = "1.0.3" +iroh-tickets = "1.0" +rand = "0.9" +subtle = "2" +thiserror.workspace = true +# io-util + net are required (io::join / copy_bidirectional / TcpListener); +# the workspace default set (rt-multi-thread, macros) lacks them and today +# they unify on only via quarto-hub's `full`. +tokio = { workspace = true, features = ["io-util", "net"] } +tracing.workspace = true + +[lints] +workspace = true diff --git a/crates/quarto-p2p/src/lib.rs b/crates/quarto-p2p/src/lib.rs new file mode 100644 index 000000000..863e29390 --- /dev/null +++ b/crates/quarto-p2p/src/lib.rs @@ -0,0 +1,92 @@ +//! P2P tunnel for `q2 preview --share` / `--join` (live share over iroh). +//! +//! The host side ([`TunnelHost`]) exposes the local preview HTTP server +//! through token-authenticated iroh QUIC bi-streams; the guest side +//! ([`TunnelClient`]) is a local loopback TCP proxy that splices each +//! accepted connection onto one such stream. The join string +//! ([`PreviewShareTicket`]) carries the host's `EndpointAddr` and a random +//! 256-bit session token — possession of the string is the capability. +//! +//! Plan: `claude-notes/plans/2026-08-03-q2-preview-live-share-iroh.md`. +//! This is the Phase 0 scaffold (bd-9gam4jqe): public API stubs only. +//! Phase 1 (bd-v8mwzpmi) implements them tests-first. + +use std::net::SocketAddr; + +use iroh::EndpointAddr; + +/// Join-string payload: the host's endpoint address plus the session token. +/// +/// Phase 1 adds the `iroh_tickets::Ticket` impl (KIND `"q2preview"`), +/// `Display`/`FromStr`, and a manual `Debug` that redacts the token — +/// no derived `Debug` here, ever, or the token leaks into logs. +pub struct PreviewShareTicket { + pub addr: EndpointAddr, + pub token: [u8; 32], +} + +/// Host side of the tunnel: an iroh `Router` whose accept loop verifies the +/// per-stream token prefix, then splices the stream onto a fresh TCP +/// connection to the local preview server. +pub struct TunnelHost; + +impl TunnelHost { + /// Bind an iroh endpoint, start the accept loop targeting `target` + /// (the loopback-bound preview server), and return the join ticket + /// plus a shutdown handle. + pub async fn spawn( + _target: SocketAddr, + ) -> Result<(PreviewShareTicket, TunnelHostHandle), TunnelError> { + todo!("Phase 1 (bd-v8mwzpmi)") + } +} + +/// Handle to a running [`TunnelHost`]. +pub struct TunnelHostHandle; + +impl TunnelHostHandle { + /// Graceful shutdown: `Router::shutdown` closes the endpoint itself. + pub async fn shutdown(self) -> Result<(), TunnelError> { + todo!("Phase 1 (bd-v8mwzpmi)") + } +} + +/// Guest side of the tunnel: dials the ticket's endpoint and serves a local +/// TCP listener; one accepted connection = one token-prefixed QUIC +/// bi-stream. Owns a re-dial loop with backoff. +pub struct TunnelClient; + +impl TunnelClient { + /// Dial the ticket's endpoint and bind the local proxy on `local` + /// (port 0 allowed). Returns the bound address and a handle. + pub async fn bind( + _ticket: PreviewShareTicket, + _local: SocketAddr, + ) -> Result<(SocketAddr, TunnelClientHandle), TunnelError> { + todo!("Phase 1 (bd-v8mwzpmi)") + } +} + +/// Handle to a running [`TunnelClient`]. Phase 1 adds a status watch +/// channel ([`TunnelStatus`]) for CLI messaging. +pub struct TunnelClientHandle; + +impl TunnelClientHandle { + /// Abort the accept loop and close the endpoint. + pub async fn shutdown(self) -> Result<(), TunnelError> { + todo!("Phase 1 (bd-v8mwzpmi)") + } +} + +/// Client connection status, surfaced to the CLI ("connected via relay", +/// "reconnecting…"). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TunnelStatus { + Connected, + Reconnecting, +} + +/// Errors from the tunnel API. Variants are added by Phase 1 alongside the +/// behavior that produces them. +#[derive(Debug, thiserror::Error)] +pub enum TunnelError {} From 4f670e0138598bc2c411264c41fb9a1bbd057834 Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Tue, 4 Aug 2026 13:24:25 +0100 Subject: [PATCH 02/22] plan: Phase 0 checks complete; user accepted gate Windows coverage (bd-9gam4jqe) --- .../plans/2026-08-03-q2-preview-live-share-iroh.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/claude-notes/plans/2026-08-03-q2-preview-live-share-iroh.md b/claude-notes/plans/2026-08-03-q2-preview-live-share-iroh.md index 63de24d12..c201bd0cc 100644 --- a/claude-notes/plans/2026-08-03-q2-preview-live-share-iroh.md +++ b/claude-notes/plans/2026-08-03-q2-preview-live-share-iroh.md @@ -623,22 +623,23 @@ proved them on a throwaway branch that never merges. --skip-hub-build` unaffected *(done 2026-08-04: workspace build green; `cargo xtask verify --skip-hub-build` → "All verification steps passed!", output inspected)* -- [ ] Re-confirm the Gate 0 static checks on the real scaffold: +- [x] Re-confirm the Gate 0 static checks on the real scaffold: `cargo tree -i iroh` from `wasm-quarto-hub-client` fails; a Windows build compiles the crate (manual or release-workflow leg — test-suite CI has no Windows matrix entry); the dep set matches what Gate 0 measured (if it drifted — e.g. feature changes — re-measure Q4 and update the gate section's numbers) - *(2026-08-04 status: **WASM closure PASS** — `cargo tree -i iroh` + *(2026-08-04: **WASM closure PASS** — `cargo tree -i iroh` from `crates/wasm-quarto-hub-client` → "package ID specification 'iroh' did not match any packages"; **dep set PASS** — Cargo.lock additions are name+version-identical to the gate branch's lockfile (141 external packages; iroh 1.0.3, iroh-tickets 1.0.0; only symmetric diff is `quarto-p2p` vs `q2-p2p-spike`), so no Q4 - re-measure; **Windows PENDING** — needs a push (user approval) to - run a windows-latest `cargo check -p quarto-p2p` leg; the gate - proved the identical dep set compiles there on 2026-08-04, run - 30894960520, so only the ~90-line stub crate itself is unproven)* + re-measure; **Windows: gate coverage accepted** — user decision + 2026-08-04: no dedicated Windows leg for Phase 0; the gate proved + the identical dep set on windows-latest (run 30894960520, + 2026-08-04) and only the ~90-line stub crate is new, so the + Windows signal rides the release workflow / later CI instead)* ## Phase 1 — `quarto-p2p` core (TDD) From ac8250033b69b11096f988404144876863a181b3 Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Wed, 5 Aug 2026 10:09:23 +0100 Subject: [PATCH 03/22] test(quarto-p2p): Phase 1 test suite, failing by design (bd-v8mwzpmi) Lands the plan's Phase 1 test specs before any implementation (TDD): ticket::{roundtrip,rejects_garbage_and_foreign_kinds,debug_redacts_token}, tunnel::{http_roundtrip_loopback,websocket_frames_survive, wrong_token_rejected,client_redials_after_connection_loss, half_close_propagates,clean_shutdown,idle_pooled_conn_survives_quic_keepalive}. All 10 fail via todo!() stubs (verified: cargo nextest run -p quarto-p2p -> 10 FAIL). Hermetic iroh only: presets::Minimal, RelayMode::Disabled, loopback binds. Public API surface extended with TunnelHostConfig/ TunnelClientConfig (EndpointPreset::HermeticLoopback for tests), TunnelClientHandle::status(), TicketParseError re-export. --- Cargo.lock | 5 + crates/quarto-p2p/Cargo.toml | 17 +- crates/quarto-p2p/src/client.rs | 52 +++ crates/quarto-p2p/src/host.rs | 69 +++ crates/quarto-p2p/src/lib.rs | 113 ++--- crates/quarto-p2p/src/ticket.rs | 55 +++ crates/quarto-p2p/tests/integration/main.rs | 9 + .../quarto-p2p/tests/integration/support.rs | 103 +++++ crates/quarto-p2p/tests/integration/ticket.rs | 114 +++++ crates/quarto-p2p/tests/integration/tunnel.rs | 413 ++++++++++++++++++ 10 files changed, 882 insertions(+), 68 deletions(-) create mode 100644 crates/quarto-p2p/src/client.rs create mode 100644 crates/quarto-p2p/src/host.rs create mode 100644 crates/quarto-p2p/src/ticket.rs create mode 100644 crates/quarto-p2p/tests/integration/main.rs create mode 100644 crates/quarto-p2p/tests/integration/support.rs create mode 100644 crates/quarto-p2p/tests/integration/ticket.rs create mode 100644 crates/quarto-p2p/tests/integration/tunnel.rs diff --git a/Cargo.lock b/Cargo.lock index e96c3a179..72bbe3419 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5208,12 +5208,17 @@ dependencies = [ name = "quarto-p2p" version = "0.13.0" dependencies = [ + "axum", + "futures", "iroh", "iroh-tickets", + "postcard", "rand 0.9.4", + "serde", "subtle", "thiserror 2.0.18", "tokio", + "tokio-tungstenite 0.29.0", "tracing", ] diff --git a/crates/quarto-p2p/Cargo.toml b/crates/quarto-p2p/Cargo.toml index 89ccc05c8..c3f7db6ee 100644 --- a/crates/quarto-p2p/Cargo.toml +++ b/crates/quarto-p2p/Cargo.toml @@ -15,14 +15,25 @@ description = "iroh-based P2P tunnel for q2 preview live share (--share / --join [dependencies] iroh = "1.0.3" iroh-tickets = "1.0" +# Ticket wire format (postcard of {addr, token}); same encoding stack +# iroh-tickets itself uses. +postcard = { version = "1.1", features = ["use-std"] } rand = "0.9" +serde.workspace = true subtle = "2" thiserror.workspace = true # io-util + net are required (io::join / copy_bidirectional / TcpListener); -# the workspace default set (rt-multi-thread, macros) lacks them and today -# they unify on only via quarto-hub's `full`. -tokio = { workspace = true, features = ["io-util", "net"] } +# time for token-read timeouts + re-dial backoff; sync for the status +# watch channel. The workspace default set (rt-multi-thread, macros) lacks +# all four and today they unify on only via quarto-hub's `full`. +tokio = { workspace = true, features = ["io-util", "net", "sync", "time"] } tracing.workspace = true +[dev-dependencies] +# Tunnel-target servers for the integration tests (plain HTTP + /ws echo). +axum = { version = "0.8", features = ["ws"] } +futures = "0.3" +tokio-tungstenite = "0.29" + [lints] workspace = true diff --git a/crates/quarto-p2p/src/client.rs b/crates/quarto-p2p/src/client.rs new file mode 100644 index 000000000..1631b6c42 --- /dev/null +++ b/crates/quarto-p2p/src/client.rs @@ -0,0 +1,52 @@ +//! Guest side of the tunnel: local loopback TCP proxy, one accepted +//! connection = one token-prefixed QUIC bi-stream; re-dial loop with +//! backoff on connection loss. + +use std::net::SocketAddr; + +use tokio::sync::watch; + +use crate::{EndpointPreset, PreviewShareTicket, TunnelError, TunnelStatus}; + +/// Configuration for [`TunnelClient::bind`]. +#[derive(Debug, Default, Clone, Copy)] +pub struct TunnelClientConfig { + /// Endpoint environment (production n0 vs. hermetic loopback). + pub preset: EndpointPreset, +} + +/// Guest side of the tunnel: dials the ticket's endpoint and serves a local +/// TCP listener; one accepted connection = one token-prefixed QUIC +/// bi-stream. Owns a re-dial loop with backoff. +pub struct TunnelClient; + +impl TunnelClient { + /// Dial the ticket's endpoint and bind the local proxy on `local` + /// (port 0 allowed). Returns the bound address and a handle. + /// + /// The initial dial happens here: an unreachable host is an error at + /// bind time (clear CLI UX), not a background retry. + pub async fn bind( + _cfg: TunnelClientConfig, + _ticket: PreviewShareTicket, + _local: SocketAddr, + ) -> Result<(SocketAddr, TunnelClientHandle), TunnelError> { + todo!("Phase 1 (bd-v8mwzpmi)") + } +} + +/// Handle to a running [`TunnelClient`]. +#[derive(Debug)] +pub struct TunnelClientHandle {} + +impl TunnelClientHandle { + /// Watch channel for CLI messaging ("connected", "reconnecting…"). + pub fn status(&self) -> watch::Receiver { + todo!("Phase 1 (bd-v8mwzpmi)") + } + + /// Abort the accept loop and close the endpoint. + pub async fn shutdown(self) -> Result<(), TunnelError> { + todo!("Phase 1 (bd-v8mwzpmi)") + } +} diff --git a/crates/quarto-p2p/src/host.rs b/crates/quarto-p2p/src/host.rs new file mode 100644 index 000000000..264327249 --- /dev/null +++ b/crates/quarto-p2p/src/host.rs @@ -0,0 +1,69 @@ +//! Host side of the tunnel: iroh `Router` accept loop → token check → +//! TCP splice onto the local preview server. + +use std::fmt; +use std::net::SocketAddr; + +use iroh::SecretKey; + +use crate::{EndpointPreset, PreviewShareTicket, TOKEN_LEN, TunnelError}; + +/// Configuration for [`TunnelHost::spawn`]. +/// +/// The defaults are the production posture (n0 preset, random identity, +/// random token, default UDP binds). The overrides exist for hermetic tests +/// — a fixed identity + token + UDP port lets a test restart the host and +/// exercise the client's re-dial path against an unchanged ticket. +#[derive(Default)] +pub struct TunnelHostConfig { + /// Endpoint environment (production n0 vs. hermetic loopback). + pub preset: EndpointPreset, + /// Fixed endpoint identity; `None` generates a fresh one. + pub secret_key: Option, + /// Fixed session token; `None` generates a random one. + pub token: Option<[u8; TOKEN_LEN]>, + /// Fixed UDP bind address; `None` uses the preset's default binds. + pub bind_addr: Option, +} + +impl fmt::Debug for TunnelHostConfig { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("TunnelHostConfig") + .field("preset", &self.preset) + .field( + "secret_key", + &self.secret_key.as_ref().map(|_| "[redacted]"), + ) + .field("token", &self.token.as_ref().map(|_| "[redacted]")) + .field("bind_addr", &self.bind_addr) + .finish() + } +} + +/// Host side of the tunnel: an iroh `Router` whose accept loop verifies the +/// per-stream token prefix, then splices the stream onto a fresh TCP +/// connection to the local preview server. +pub struct TunnelHost; + +impl TunnelHost { + /// Bind an iroh endpoint, start the accept loop targeting `target` + /// (the loopback-bound preview server), and return the join ticket + /// plus a shutdown handle. + pub async fn spawn( + _cfg: TunnelHostConfig, + _target: SocketAddr, + ) -> Result<(PreviewShareTicket, TunnelHostHandle), TunnelError> { + todo!("Phase 1 (bd-v8mwzpmi)") + } +} + +/// Handle to a running [`TunnelHost`]. +#[derive(Debug)] +pub struct TunnelHostHandle {} + +impl TunnelHostHandle { + /// Graceful shutdown: `Router::shutdown` closes the endpoint itself. + pub async fn shutdown(self) -> Result<(), TunnelError> { + todo!("Phase 1 (bd-v8mwzpmi)") + } +} diff --git a/crates/quarto-p2p/src/lib.rs b/crates/quarto-p2p/src/lib.rs index 863e29390..0a271054e 100644 --- a/crates/quarto-p2p/src/lib.rs +++ b/crates/quarto-p2p/src/lib.rs @@ -8,74 +8,40 @@ //! 256-bit session token — possession of the string is the capability. //! //! Plan: `claude-notes/plans/2026-08-03-q2-preview-live-share-iroh.md`. -//! This is the Phase 0 scaffold (bd-9gam4jqe): public API stubs only. -//! Phase 1 (bd-v8mwzpmi) implements them tests-first. -use std::net::SocketAddr; +mod client; +mod host; +mod ticket; -use iroh::EndpointAddr; +pub use client::{TunnelClient, TunnelClientConfig, TunnelClientHandle}; +pub use host::{TunnelHost, TunnelHostConfig, TunnelHostHandle}; +/// Typed error for [`PreviewShareTicket`] parsing (`FromStr`). +pub use iroh_tickets::ParseError as TicketParseError; +pub use ticket::PreviewShareTicket; -/// Join-string payload: the host's endpoint address plus the session token. +/// ALPN for the preview tunnel protocol. /// -/// Phase 1 adds the `iroh_tickets::Ticket` impl (KIND `"q2preview"`), -/// `Display`/`FromStr`, and a manual `Debug` that redacts the token — -/// no derived `Debug` here, ever, or the token leaks into logs. -pub struct PreviewShareTicket { - pub addr: EndpointAddr, - pub token: [u8; 32], -} - -/// Host side of the tunnel: an iroh `Router` whose accept loop verifies the -/// per-stream token prefix, then splices the stream onto a fresh TCP -/// connection to the local preview server. -pub struct TunnelHost; - -impl TunnelHost { - /// Bind an iroh endpoint, start the accept loop targeting `target` - /// (the loopback-bound preview server), and return the join ticket - /// plus a shutdown handle. - pub async fn spawn( - _target: SocketAddr, - ) -> Result<(PreviewShareTicket, TunnelHostHandle), TunnelError> { - todo!("Phase 1 (bd-v8mwzpmi)") - } -} - -/// Handle to a running [`TunnelHost`]. -pub struct TunnelHostHandle; - -impl TunnelHostHandle { - /// Graceful shutdown: `Router::shutdown` closes the endpoint itself. - pub async fn shutdown(self) -> Result<(), TunnelError> { - todo!("Phase 1 (bd-v8mwzpmi)") - } -} +/// The trailing `/0` is the protocol version tag; a breaking wire change +/// bumps it together with the ticket KIND. +pub const ALPN: &[u8] = b"q2/preview-tunnel/0"; -/// Guest side of the tunnel: dials the ticket's endpoint and serves a local -/// TCP listener; one accepted connection = one token-prefixed QUIC -/// bi-stream. Owns a re-dial loop with backoff. -pub struct TunnelClient; +/// Session-token length in bytes (256-bit). +pub const TOKEN_LEN: usize = 32; -impl TunnelClient { - /// Dial the ticket's endpoint and bind the local proxy on `local` - /// (port 0 allowed). Returns the bound address and a handle. - pub async fn bind( - _ticket: PreviewShareTicket, - _local: SocketAddr, - ) -> Result<(SocketAddr, TunnelClientHandle), TunnelError> { - todo!("Phase 1 (bd-v8mwzpmi)") - } -} - -/// Handle to a running [`TunnelClient`]. Phase 1 adds a status watch -/// channel ([`TunnelStatus`]) for CLI messaging. -pub struct TunnelClientHandle; - -impl TunnelClientHandle { - /// Abort the accept loop and close the endpoint. - pub async fn shutdown(self) -> Result<(), TunnelError> { - todo!("Phase 1 (bd-v8mwzpmi)") - } +/// Which iroh environment an endpoint binds into. +/// +/// Production code uses [`EndpointPreset::N0`]; tests use +/// [`EndpointPreset::HermeticLoopback`] so no n0 infrastructure (relays, +/// pkarr, DNS) is touched in CI. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum EndpointPreset { + /// n0 production defaults: pkarr publish/resolve + DNS lookup + + /// default relays. + #[default] + N0, + /// Hermetic mode for tests: crypto only, relays disabled, bound to + /// loopback. Peers are reachable only via explicit ticket addresses. + HermeticLoopback, } /// Client connection status, surfaced to the CLI ("connected via relay", @@ -86,7 +52,24 @@ pub enum TunnelStatus { Reconnecting, } -/// Errors from the tunnel API. Variants are added by Phase 1 alongside the -/// behavior that produces them. +pub(crate) type BoxedError = Box; + +/// Errors from the tunnel API. #[derive(Debug, thiserror::Error)] -pub enum TunnelError {} +pub enum TunnelError { + /// Binding the iroh endpoint (or its UDP socket) failed. + #[error("failed to bind tunnel endpoint")] + Bind(#[source] BoxedError), + /// The endpoint never reported a dialable address. + #[error("tunnel endpoint has no dialable address")] + NoAddress, + /// Dialing the share host failed. + #[error("could not reach the share host")] + Connect(#[source] BoxedError), + /// The local TCP proxy listener failed. + #[error("local tunnel proxy error")] + Proxy(#[source] std::io::Error), + /// Graceful shutdown did not complete cleanly. + #[error("tunnel shutdown failed")] + Shutdown(#[source] BoxedError), +} diff --git a/crates/quarto-p2p/src/ticket.rs b/crates/quarto-p2p/src/ticket.rs new file mode 100644 index 000000000..07ac81f10 --- /dev/null +++ b/crates/quarto-p2p/src/ticket.rs @@ -0,0 +1,55 @@ +//! The `q2preview…` join string: host `EndpointAddr` + session token. + +use std::fmt; +use std::str::FromStr; + +use iroh::EndpointAddr; +use iroh_tickets::{ParseError, Ticket}; + +use crate::TOKEN_LEN; + +/// Join-string payload: the host's endpoint address plus the session token. +/// +/// Possession of the string is the capability: the token authenticates the +/// guest to the host (per-stream prefix), while QUIC's handshake against the +/// pinned `EndpointId` authenticates the host to the guest. +/// +/// `Debug` redacts the token; `Display` prints the full join string (which +/// necessarily encodes the token — that is what a join string is for). +#[derive(Clone, PartialEq, Eq)] +pub struct PreviewShareTicket { + pub addr: EndpointAddr, + pub token: [u8; TOKEN_LEN], +} + +impl Ticket for PreviewShareTicket { + const KIND: &'static str = "q2preview"; + + fn encode_bytes(&self) -> Vec { + todo!("Phase 1 (bd-v8mwzpmi)") + } + + fn decode_bytes(_bytes: &[u8]) -> Result { + todo!("Phase 1 (bd-v8mwzpmi)") + } +} + +impl fmt::Display for PreviewShareTicket { + fn fmt(&self, _f: &mut fmt::Formatter<'_>) -> fmt::Result { + todo!("Phase 1 (bd-v8mwzpmi)") + } +} + +impl FromStr for PreviewShareTicket { + type Err = ParseError; + + fn from_str(_s: &str) -> Result { + todo!("Phase 1 (bd-v8mwzpmi)") + } +} + +impl fmt::Debug for PreviewShareTicket { + fn fmt(&self, _f: &mut fmt::Formatter<'_>) -> fmt::Result { + todo!("Phase 1 (bd-v8mwzpmi)") + } +} diff --git a/crates/quarto-p2p/tests/integration/main.rs b/crates/quarto-p2p/tests/integration/main.rs new file mode 100644 index 000000000..549a586ca --- /dev/null +++ b/crates/quarto-p2p/tests/integration/main.rs @@ -0,0 +1,9 @@ +//! Single integration-test binary for quarto-p2p (see +//! `.claude/rules/integration-tests.md`). +//! +//! All tests are hermetic: `presets::Minimal`, `RelayMode::Disabled`, +//! explicit loopback `TransportAddr::Ip` addrs — no n0 infrastructure in CI. + +pub mod support; +pub mod ticket; +pub mod tunnel; diff --git a/crates/quarto-p2p/tests/integration/support.rs b/crates/quarto-p2p/tests/integration/support.rs new file mode 100644 index 000000000..65a0b6c77 --- /dev/null +++ b/crates/quarto-p2p/tests/integration/support.rs @@ -0,0 +1,103 @@ +//! Shared helpers for the quarto-p2p integration tests. + +use std::net::SocketAddr; +use std::time::Duration; + +use quarto_p2p::{EndpointPreset, TunnelClientConfig, TunnelHostConfig}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpStream; + +/// Generous cap for individual awaits so a broken tunnel fails the test +/// instead of hanging it. +pub const STEP_TIMEOUT: Duration = Duration::from_secs(20); + +pub fn hermetic_host_cfg() -> TunnelHostConfig { + TunnelHostConfig { + preset: EndpointPreset::HermeticLoopback, + ..Default::default() + } +} + +pub fn hermetic_client_cfg() -> TunnelClientConfig { + TunnelClientConfig { + preset: EndpointPreset::HermeticLoopback, + } +} + +/// Serve `app` on a fresh loopback port; returns the bound address. +/// +/// The server task is detached — it dies with the test process, which is +/// fine for these hermetic tests. +pub async fn spawn_http_target(app: axum::Router) -> SocketAddr { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind http target"); + let addr = listener.local_addr().expect("target local_addr"); + tokio::spawn(async move { + axum::serve(listener, app).await.expect("axum serve"); + }); + addr +} + +/// Raw HTTP/1.1 GET with `Connection: close`; returns the full response +/// text (status line + headers + body). Panics on I/O failure. +pub async fn http_get_close(addr: SocketAddr, path: &str) -> String { + try_http_get_close(addr, path) + .await + .expect("http_get_close roundtrip") +} + +/// Non-panicking variant of [`http_get_close`] for eventually-succeeds +/// loops (e.g. while the client re-dials). +pub async fn try_http_get_close(addr: SocketAddr, path: &str) -> std::io::Result { + let mut stream = TcpStream::connect(addr).await?; + stream + .write_all( + format!("GET {path} HTTP/1.1\r\nHost: tunnel-test\r\nConnection: close\r\n\r\n") + .as_bytes(), + ) + .await?; + let mut buf = Vec::new(); + stream.read_to_end(&mut buf).await?; + Ok(String::from_utf8_lossy(&buf).into_owned()) +} + +/// Send a GET on an already-open connection *without* `Connection: close` +/// and read exactly one response (headers + `Content-Length` body). +/// Returns the full response text; the connection stays usable. +pub async fn http_get_keepalive(stream: &mut TcpStream, path: &str) -> String { + stream + .write_all(format!("GET {path} HTTP/1.1\r\nHost: tunnel-test\r\n\r\n").as_bytes()) + .await + .expect("write keep-alive request"); + + // Read until end of headers. + let mut buf = Vec::new(); + let header_end = loop { + let mut byte = [0u8; 1]; + let n = stream.read(&mut byte).await.expect("read response header"); + assert!(n > 0, "connection closed while reading response headers"); + buf.push(byte[0]); + if buf.ends_with(b"\r\n\r\n") { + break buf.len(); + } + assert!(buf.len() < 64 * 1024, "response headers too large"); + }; + + let headers = String::from_utf8_lossy(&buf[..header_end]).into_owned(); + let content_length: usize = headers + .lines() + .find_map(|l| { + let (name, value) = l.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse().expect("content-length value")) + }) + .expect("response has a Content-Length header"); + + let mut body = vec![0u8; content_length]; + stream + .read_exact(&mut body) + .await + .expect("read response body"); + format!("{headers}{}", String::from_utf8_lossy(&body)) +} diff --git a/crates/quarto-p2p/tests/integration/ticket.rs b/crates/quarto-p2p/tests/integration/ticket.rs new file mode 100644 index 000000000..540fe54b6 --- /dev/null +++ b/crates/quarto-p2p/tests/integration/ticket.rs @@ -0,0 +1,114 @@ +//! `PreviewShareTicket` encode/decode/redaction tests (plan Phase 1). + +use iroh::{EndpointAddr, SecretKey, TransportAddr}; +use iroh_tickets::endpoint::EndpointTicket; +use quarto_p2p::{PreviewShareTicket, TicketParseError}; + +fn sample_addr() -> EndpointAddr { + let id = SecretKey::from_bytes(&[7u8; 32]).public(); + EndpointAddr::from_parts( + id, + [ + TransportAddr::Relay("https://relay.example.com./".parse().unwrap()), + TransportAddr::Ip("127.0.0.1:4433".parse().unwrap()), + TransportAddr::Ip("[::1]:4433".parse().unwrap()), + ], + ) +} + +fn sample_token() -> [u8; 32] { + core::array::from_fn(|i| [0xde, 0xad, 0xbe, 0xef][i % 4]) +} + +#[test] +fn roundtrip() { + let ticket = PreviewShareTicket { + addr: sample_addr(), + token: sample_token(), + }; + + let s = ticket.to_string(); + assert!( + s.starts_with("q2preview"), + "join string must start with the q2preview KIND, got: {s}" + ); + + let parsed: PreviewShareTicket = s.parse().expect("roundtrip parse"); + assert_eq!(parsed, ticket); +} + +#[test] +fn rejects_garbage_and_foreign_kinds() { + // Empty string: no KIND prefix. + let err = "".parse::().unwrap_err(); + assert!( + matches!(err, TicketParseError::Kind { .. }), + "empty string should fail on the KIND prefix, got: {err:?}" + ); + + // Random text: no KIND prefix either. + let err = "hello world".parse::().unwrap_err(); + assert!( + matches!(err, TicketParseError::Kind { .. }), + "non-ticket text should fail on the KIND prefix, got: {err:?}" + ); + + // Correct prefix but not base32. + let err = "q2preview!!!not-base32!!!" + .parse::() + .unwrap_err(); + assert!( + matches!(err, TicketParseError::Encoding { .. }), + "invalid base32 should fail decoding, got: {err:?}" + ); + + // Correct prefix, valid base32 ("zzzzzzzz" decodes to 0xff bytes), + // but garbage postcard payload. + let err = "q2previewzzzzzzzz" + .parse::() + .unwrap_err(); + assert!( + matches!(err, TicketParseError::Postcard { .. }), + "garbage payload should fail postcard decoding, got: {err:?}" + ); + + // A bare iroh EndpointTicket (KIND "endpoint") is a foreign kind. + let foreign = EndpointTicket::new(sample_addr()).to_string(); + assert!(foreign.starts_with("endpoint")); + let err = foreign.parse::().unwrap_err(); + assert!( + matches!(err, TicketParseError::Kind { .. }), + "foreign ticket kinds must be rejected, got: {err:?}" + ); +} + +#[test] +fn debug_redacts_token() { + let ticket = PreviewShareTicket { + addr: sample_addr(), + token: sample_token(), + }; + + let dbg = format!("{ticket:?}"); + + // Not as hex… + assert!( + !dbg.to_lowercase().contains("deadbeef"), + "Debug leaked the token as hex: {dbg}" + ); + // …not as a derived byte-array dump (0xde, 0xad = 222, 173)… + assert!( + !dbg.contains("222, 173"), + "Debug leaked the token as a byte array: {dbg}" + ); + // …and not by embedding the full join string (base32 encodes the token). + assert!( + !dbg.contains(&ticket.to_string()), + "Debug embedded the full join string: {dbg}" + ); + + assert!( + dbg.contains("redacted"), + "Debug should mark the token as redacted: {dbg}" + ); +} diff --git a/crates/quarto-p2p/tests/integration/tunnel.rs b/crates/quarto-p2p/tests/integration/tunnel.rs new file mode 100644 index 000000000..e5a68866a --- /dev/null +++ b/crates/quarto-p2p/tests/integration/tunnel.rs @@ -0,0 +1,413 @@ +//! Tunnel behavior tests (plan Phase 1): HTTP + WebSocket splicing, token +//! auth, re-dial, half-close, shutdown. All hermetic — no n0 infrastructure. + +use std::net::SocketAddr; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::{Duration, Instant}; + +use axum::Router; +use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; +use axum::routing::get; +use futures::{SinkExt, StreamExt}; +use iroh::{SecretKey, TransportAddr}; +use quarto_p2p::{ALPN, EndpointPreset, TunnelClient, TunnelHost, TunnelHostConfig, TunnelStatus}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; +use tokio::time::timeout; +use tokio_tungstenite::connect_async; +use tokio_tungstenite::tungstenite::protocol::Message as WsMessage; + +use crate::support::{ + STEP_TIMEOUT, hermetic_client_cfg, hermetic_host_cfg, http_get_close, http_get_keepalive, + spawn_http_target, try_http_get_close, +}; + +fn any_loopback() -> SocketAddr { + "127.0.0.1:0".parse().unwrap() +} + +/// Host + client pair in front of `target`, hermetic on both ends. +async fn spawn_tunnel_pair( + target: SocketAddr, +) -> ( + SocketAddr, + quarto_p2p::TunnelHostHandle, + quarto_p2p::TunnelClientHandle, +) { + let (ticket, host) = TunnelHost::spawn(hermetic_host_cfg(), target) + .await + .expect("spawn tunnel host"); + let (local, client) = TunnelClient::bind(hermetic_client_cfg(), ticket, any_loopback()) + .await + .expect("bind tunnel client"); + (local, host, client) +} + +#[tokio::test(flavor = "multi_thread")] +async fn http_roundtrip_loopback() { + let target = spawn_http_target( + Router::new().route("/body", get(|| async { "hello-through-the-tunnel" })), + ) + .await; + let (local, host, client) = spawn_tunnel_pair(target).await; + + // ≥8 concurrent TCP connections = ≥8 concurrent QUIC bi-streams. + let mut tasks = Vec::new(); + for _ in 0..8 { + tasks.push(tokio::spawn( + async move { http_get_close(local, "/body").await }, + )); + } + for task in tasks { + let response = timeout(STEP_TIMEOUT, task) + .await + .expect("request timed out") + .expect("request task panicked"); + assert!( + response.starts_with("HTTP/1.1 200"), + "expected a 200 response, got: {response}" + ); + assert!( + response.contains("hello-through-the-tunnel"), + "body did not survive the tunnel: {response}" + ); + } + + client.shutdown().await.expect("client shutdown"); + host.shutdown().await.expect("host shutdown"); +} + +async fn ws_echo(ws: WebSocketUpgrade) -> axum::response::Response { + ws.on_upgrade(|mut socket: WebSocket| async move { + while let Some(Ok(msg)) = socket.recv().await { + if matches!(msg, Message::Close(_)) { + break; + } + if socket.send(msg).await.is_err() { + break; + } + } + }) +} + +#[tokio::test(flavor = "multi_thread")] +async fn websocket_frames_survive() { + let target = spawn_http_target(Router::new().route("/ws", get(ws_echo))).await; + let (local, host, client) = spawn_tunnel_pair(target).await; + + let (mut ws, _response) = timeout(STEP_TIMEOUT, connect_async(format!("ws://{local}/ws"))) + .await + .expect("ws upgrade timed out") + .expect("ws upgrade failed"); + + for i in 0..3 { + let text = format!("frame-{i}"); + ws.send(WsMessage::text(text.clone())) + .await + .expect("send text frame"); + let echoed = timeout(STEP_TIMEOUT, ws.next()) + .await + .expect("echo timed out") + .expect("ws stream ended early") + .expect("ws read failed"); + assert_eq!(echoed.into_text().expect("text frame").as_str(), text); + } + + let payload = vec![0u8, 1, 2, 3, 255]; + ws.send(WsMessage::binary(payload.clone())) + .await + .expect("send binary frame"); + let echoed = timeout(STEP_TIMEOUT, ws.next()) + .await + .expect("binary echo timed out") + .expect("ws stream ended early") + .expect("ws read failed"); + match echoed { + WsMessage::Binary(bytes) => assert_eq!(bytes.as_ref(), payload.as_slice()), + other => panic!("expected a binary echo, got: {other:?}"), + } + + ws.close(None).await.expect("ws close"); + + client.shutdown().await.expect("client shutdown"); + host.shutdown().await.expect("host shutdown"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn wrong_token_rejected() { + // Raw TCP target that counts accepted connections; a rejected stream + // must never produce one. + let accepts = Arc::new(AtomicUsize::new(0)); + let listener = TcpListener::bind(any_loopback()) + .await + .expect("bind target"); + let target = listener.local_addr().expect("target addr"); + { + let accepts = accepts.clone(); + tokio::spawn(async move { + loop { + let _ = listener.accept().await; + accepts.fetch_add(1, Ordering::SeqCst); + } + }); + } + + // Fix the session token so the wrong one below is wrong by construction. + let cfg = TunnelHostConfig { + preset: EndpointPreset::HermeticLoopback, + token: Some([0xAA; 32]), + ..Default::default() + }; + let (ticket, host) = TunnelHost::spawn(cfg, target).await.expect("spawn host"); + + let dialer = iroh::Endpoint::builder(iroh::endpoint::presets::Minimal) + .relay_mode(iroh::RelayMode::Disabled) + .clear_ip_transports() + .bind_addr("127.0.0.1:0") + .expect("loopback bind addr") + .bind() + .await + .expect("bind raw dialer"); + + // Case 1: full-length token with the wrong bytes. + let conn = dialer + .connect(ticket.addr.clone(), ALPN) + .await + .expect("dial host"); + let (mut send, mut recv) = conn.open_bi().await.expect("open_bi"); + send.write_all(&[0u8; 32]).await.expect("write wrong token"); + let read = timeout(STEP_TIMEOUT, recv.read_to_end(16)) + .await + .expect("host did not react to the wrong token"); + assert!( + read.is_err(), + "stream should be reset after a wrong token, got: {read:?}" + ); + let closed = timeout(STEP_TIMEOUT, conn.closed()) + .await + .expect("host did not close the connection after a wrong token"); + drop(closed); + + // Case 2: short token (stream finished after 5 bytes). + let conn = dialer + .connect(ticket.addr.clone(), ALPN) + .await + .expect("re-dial host"); + let (mut send, mut recv) = conn.open_bi().await.expect("open_bi"); + send.write_all(&[0xAA; 5]).await.expect("write short token"); + send.finish().expect("finish short stream"); + let read = timeout(STEP_TIMEOUT, recv.read_to_end(16)) + .await + .expect("host did not react to the short token"); + assert!( + read.is_err(), + "stream should be reset after a short token, got: {read:?}" + ); + + // The target must never have seen a TCP connection. + tokio::time::sleep(Duration::from_millis(250)).await; + assert_eq!( + accepts.load(Ordering::SeqCst), + 0, + "unauthenticated streams must not reach the target" + ); + + dialer.close().await; + host.shutdown().await.expect("host shutdown"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn client_redials_after_connection_loss() { + let target = spawn_http_target(Router::new().route("/", get(|| async { "redial-ok" }))).await; + + // Fixed identity + token + (after first spawn) UDP port, so the restarted + // host is reachable via the unchanged ticket. + let secret_key = SecretKey::from_bytes(&[13u8; 32]); + let token = [0x42u8; 32]; + let cfg = TunnelHostConfig { + preset: EndpointPreset::HermeticLoopback, + secret_key: Some(secret_key.clone()), + token: Some(token), + bind_addr: None, + }; + let (ticket, first_host) = TunnelHost::spawn(cfg, target).await.expect("spawn host"); + let udp_addr = ticket + .addr + .addrs + .iter() + .find_map(|a| match a { + TransportAddr::Ip(sa) => Some(*sa), + _ => None, + }) + .expect("hermetic ticket has an ip transport addr"); + + let (local, client) = TunnelClient::bind(hermetic_client_cfg(), ticket.clone(), any_loopback()) + .await + .expect("bind client"); + let mut status = client.status(); + assert_eq!(*status.borrow(), TunnelStatus::Connected); + + let first = http_get_close(local, "/").await; + assert!( + first.contains("redial-ok"), + "sanity roundtrip failed: {first}" + ); + + // Drop the host-side connection by shutting the host down entirely. + first_host.shutdown().await.expect("first host shutdown"); + + timeout( + STEP_TIMEOUT, + status.wait_for(|s| *s == TunnelStatus::Reconnecting), + ) + .await + .expect("client never noticed the connection loss") + .expect("status channel closed"); + + // Restart the host: same identity, token, target, and UDP port. + let cfg = TunnelHostConfig { + preset: EndpointPreset::HermeticLoopback, + secret_key: Some(secret_key), + token: Some(token), + bind_addr: Some(udp_addr), + }; + let (restart_ticket, second_host) = TunnelHost::spawn(cfg, target) + .await + .expect("respawn host on the same udp addr"); + assert_eq!(restart_ticket.addr.id, ticket.addr.id); + + // The next local TCP connections succeed once the client re-dialed. + let deadline = Instant::now() + Duration::from_secs(30); + let mut recovered = false; + while Instant::now() < deadline { + if let Ok(response) = try_http_get_close(local, "/").await { + if response.contains("redial-ok") { + recovered = true; + break; + } + } + tokio::time::sleep(Duration::from_millis(500)).await; + } + assert!(recovered, "tunnel did not recover after the host restarted"); + + timeout( + STEP_TIMEOUT, + status.wait_for(|s| *s == TunnelStatus::Connected), + ) + .await + .expect("status never returned to Connected") + .expect("status channel closed"); + + client.shutdown().await.expect("client shutdown"); + second_host.shutdown().await.expect("second host shutdown"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn half_close_propagates() { + // Raw TCP target driven by a task whose asserts propagate via join. + let listener = TcpListener::bind(any_loopback()) + .await + .expect("bind target"); + let target = listener.local_addr().expect("target addr"); + let target_task = tokio::spawn(async move { + let (mut sock, _) = listener.accept().await.expect("accept"); + let mut buf = [0u8; 4]; + sock.read_exact(&mut buf).await.expect("read ping"); + assert_eq!(&buf, b"ping"); + + // The guest shut down its write half → we must see EOF… + let n = sock.read(&mut [0u8; 8]).await.expect("read eof"); + assert_eq!(n, 0, "expected EOF after guest write-half shutdown"); + + // …while the reverse direction keeps flowing. + sock.write_all(b"pong").await.expect("write pong"); + sock.shutdown().await.expect("target write shutdown"); + }); + + let (local, host, client) = spawn_tunnel_pair(target).await; + + let mut guest = TcpStream::connect(local) + .await + .expect("connect local proxy"); + guest.write_all(b"ping").await.expect("write ping"); + guest.shutdown().await.expect("guest write-half shutdown"); + + let mut buf = [0u8; 4]; + timeout(STEP_TIMEOUT, guest.read_exact(&mut buf)) + .await + .expect("pong timed out") + .expect("read pong"); + assert_eq!(&buf, b"pong"); + + let n = timeout(STEP_TIMEOUT, guest.read(&mut [0u8; 8])) + .await + .expect("guest EOF timed out") + .expect("read guest eof"); + assert_eq!(n, 0, "expected EOF after target write shutdown"); + + timeout(STEP_TIMEOUT, target_task) + .await + .expect("target task timed out") + .expect("target task panicked"); + + client.shutdown().await.expect("client shutdown"); + host.shutdown().await.expect("host shutdown"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn clean_shutdown() { + let target = spawn_http_target(Router::new().route("/", get(|| async { "shutdown-ok" }))).await; + let (local, host, client) = spawn_tunnel_pair(target).await; + + // Exercise the pair once so shutdown happens on a live tunnel. + let response = http_get_close(local, "/").await; + assert!(response.contains("shutdown-ok")); + + timeout(STEP_TIMEOUT, client.shutdown()) + .await + .expect("client shutdown hung") + .expect("client shutdown failed"); + + // The local proxy port must be unbound again. + let connect = TcpStream::connect(local).await; + assert!( + connect.is_err(), + "local proxy port still accepting after shutdown" + ); + + timeout(STEP_TIMEOUT, host.shutdown()) + .await + .expect("host shutdown hung") + .expect("host shutdown failed"); +} + +/// Verifies the plan's QUIC-keep-alive-vs-browser-connection-pooling item: +/// iroh's default 5 s keep-alive must hold the QUIC connection (30 s idle +/// timeout) open underneath an idle pooled HTTP/1.1 connection, so the next +/// request on that pooled TCP connection still succeeds. Idles for 35 s by +/// design — this is deliberately the slowest test in the crate. +#[tokio::test(flavor = "multi_thread")] +async fn idle_pooled_conn_survives_quic_keepalive() { + let target = + spawn_http_target(Router::new().route("/", get(|| async { "keepalive-ok" }))).await; + let (local, host, client) = spawn_tunnel_pair(target).await; + + let mut pooled = TcpStream::connect(local) + .await + .expect("connect local proxy"); + let first = http_get_keepalive(&mut pooled, "/").await; + assert!(first.contains("keepalive-ok"), "first response: {first}"); + + // Longer than the 30 s QUIC connection idle timeout. + tokio::time::sleep(Duration::from_secs(35)).await; + + let second = http_get_keepalive(&mut pooled, "/").await; + assert!( + second.contains("keepalive-ok"), + "pooled connection died during idle: {second}" + ); + + client.shutdown().await.expect("client shutdown"); + host.shutdown().await.expect("host shutdown"); +} From a6dc60e5b80085aea0532aaae57a1be4f6c8ce49 Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Wed, 5 Aug 2026 10:32:44 +0100 Subject: [PATCH 04/22] =?UTF-8?q?feat(quarto-p2p):=20Phase=201=20core=20?= =?UTF-8?q?=E2=80=94=20ticket,=20TunnelHost,=20TunnelClient=20(bd-v8mwzpmi?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the tunnel under the tests landed in c146ca6d (TDD; all 10 were failing via todo!() stubs, now 10/10 pass): - ticket.rs: iroh_tickets::Ticket impl, KIND "q2preview", postcard wire format following the versioned-enum convention (Variant1 {id, addrs, token}); Display/FromStr via encode_string/decode_string; manual Debug redacts the token. - host.rs: Router accept loop; per stream read_exact of the 32-byte token under a 10 s timeout, subtle::ConstantTimeEq compare, then terminated splice copy_bidirectional(join(recv, send), tcp). Bad or short token => stream reset + connection close + warn log with remote_id().fmt_short(). N0 preset wraps online() in a 10 s timeout and degrades to direct/LAN-only with a warning. - client.rs: MemoryLookup seeded from the ticket; local TcpListener; one TCP conn = one token-prefixed bi-stream. A supervisor task parked on conn.closed() re-dials with expo backoff (250 ms..5 s, 10 s per attempt) and drives the Connected/Reconnecting status watch; per-conn handlers wait on the watch with a 30 s budget, then drop the conn. - Shutdown: host router.shutdown() (handles JoinError); client awaits the aborted accept-loop task so the local port is provably unbound, then closes the endpoint. Hermetic test posture: EndpointPreset::HermeticLoopback = presets:: Minimal + RelayMode::Disabled + loopback binds; TunnelHostConfig's secret_key/token/bind_addr overrides exist for the restart-same- identity re-dial test. No n0 infrastructure in CI. Verification (output inspected): cargo nextest run -p quarto-p2p 10/10; cargo build --workspace; cargo nextest run --workspace 10873 passed; cargo xtask verify --skip-hub-build all green; cargo tree -i iroh from wasm-quarto-hub-client still fails (WASM closure clean). --- .../2026-08-03-q2-preview-live-share-iroh.md | 63 ++++-- crates/quarto-p2p/src/client.rs | 202 +++++++++++++++++- crates/quarto-p2p/src/host.rs | 164 +++++++++++++- crates/quarto-p2p/src/lib.rs | 42 ++++ crates/quarto-p2p/src/ticket.rs | 50 ++++- crates/quarto-p2p/tests/integration/tunnel.rs | 10 +- 6 files changed, 483 insertions(+), 48 deletions(-) diff --git a/claude-notes/plans/2026-08-03-q2-preview-live-share-iroh.md b/claude-notes/plans/2026-08-03-q2-preview-live-share-iroh.md index c201bd0cc..aff6bc02d 100644 --- a/claude-notes/plans/2026-08-03-q2-preview-live-share-iroh.md +++ b/claude-notes/plans/2026-08-03-q2-preview-live-share-iroh.md @@ -650,36 +650,52 @@ infrastructure in CI**. **Test specs (write these first, watch them fail):** -- [ ] `ticket::roundtrip` — ticket with relay + ip addrs + token → +*(all landed failing-first in `c146ca6d` — 10/10 FAIL via `todo!()` stubs, +output inspected — then went green with the implementation, 2026-08-05; +suite lives in `crates/quarto-p2p/tests/integration/{ticket,tunnel}.rs`)* + +- [x] `ticket::roundtrip` — ticket with relay + ip addrs + token → `to_string()` (starts with `q2preview`) → `parse()` → equal -- [ ] `ticket::rejects_garbage_and_foreign_kinds` — empty string, random +- [x] `ticket::rejects_garbage_and_foreign_kinds` — empty string, random base32, a bare iroh `EndpointTicket` string (`endpoint…`) all fail with a typed error -- [ ] `ticket::debug_redacts_token` — `format!("{ticket:?}")` does not contain - the token bytes/hex -- [ ] `tunnel::http_roundtrip_loopback` — tiny axum server as target; host +- [x] `ticket::debug_redacts_token` — `format!("{ticket:?}")` does not contain + the token bytes/hex *(also asserts Debug does not embed the full join + string, which would leak the token via base32)* +- [x] `tunnel::http_roundtrip_loopback` — tiny axum server as target; host endpoint + `TunnelHost::spawn`; client endpoint + `TunnelClient::bind`; raw HTTP/1.1 GET through the client's local port returns the body; repeat over ≥8 **concurrent** connections (concurrent QUIC streams) -- [ ] `tunnel::websocket_frames_survive` — target is an axum `/ws` echo; +- [x] `tunnel::websocket_frames_survive` — target is an axum `/ws` echo; `tokio-tungstenite` client through the local port; upgrade + a few frames round-trip (proves the splice handles long-lived duplex traffic) -- [ ] `tunnel::wrong_token_rejected` — stream with a wrong/short token is +- [x] `tunnel::wrong_token_rejected` — stream with a wrong/short token is reset; the target server sees **zero** TCP connections (count accepts) -- [ ] `tunnel::client_redials_after_connection_loss` — drop the host-side +- [x] `tunnel::client_redials_after_connection_loss` — drop the host-side connection; next local TCP conn succeeds after client re-dial -- [ ] `tunnel::half_close_propagates` — guest-side TCP write-half shutdown + *(host restarted with fixed secret key + token + UDP port so the + unchanged ticket stays valid; asserts the status watch flips to + `Reconnecting` and back to `Connected`)* +- [x] `tunnel::half_close_propagates` — guest-side TCP write-half shutdown reaches the target as read-EOF (and the reverse direction), while the other direction keeps flowing; guards the splice's EOF ↔ `SendStream::finish()` mapping, which `websocket_frames_survive`'s symmetric traffic does not exercise -- [ ] `tunnel::clean_shutdown` — `shutdown()` on both handles completes +- [x] `tunnel::clean_shutdown` — `shutdown()` on both handles completes without hangs and unbinds the local port **Implementation:** -- [ ] `ticket.rs` — struct + `iroh_tickets::Ticket` impl + `FromStr`/`Display` -- [ ] `host.rs` — `Endpoint` (preset injectable for tests) + `Router` with a +*(implementation complete 2026-08-05; verification: 10/10 crate tests +green, `cargo nextest run --workspace` 10873 passed, `cargo xtask verify +--skip-hub-build` "All verification steps passed!", `cargo tree -i iroh` +from `wasm-quarto-hub-client` still fails — output inspected for all)* + +- [x] `ticket.rs` — struct + `iroh_tickets::Ticket` impl + `FromStr`/`Display` + *(postcard wire format follows iroh-tickets' versioned-enum convention: + `TicketWireFormat::Variant1 { id, addrs, token }`; manual `Debug` + redacts the token)* +- [x] `host.rs` — `Endpoint` (preset injectable for tests) + `Router` with a `ProtocolHandler` whose `accept()` loops on `accept_bi()`, spawning per stream: `read_exact` 32-byte token under a 10 s timeout → constant-time compare → `TcpStream::connect(target)` → @@ -690,7 +706,11 @@ infrastructure in CI**. `SendStream::finish()` (QUIC FIN), and a stream FIN must become TCP write-shutdown — verify with `tunnel::half_close_propagates` rather than assuming the adapter chain does it -- [ ] **QUIC keep-alive vs. browser connection pooling:** browsers hold idle + *(done; preset injection is `TunnelHostConfig { preset, secret_key, + token, bind_addr }` — the last three exist for the hermetic + restart-same-identity re-dial test. N0 spawn wraps `online()` in the + plan's 10 s timeout and warns + degrades to direct/LAN-only on miss)* +- [x] **QUIC keep-alive vs. browser connection pooling:** browsers hold idle pooled HTTP/1.1 connections open for minutes; if the iroh connection's idle timeout fires in between, the next request on a pooled TCP conn fails before the client re-dials. iroh's defaults already cover this — @@ -702,13 +722,26 @@ infrastructure in CI**. (`endpoint.rs:669`) — **not** `TransportConfig`, which in iroh v1 is an unrelated internal socket-transport enum. Do not rely on the SPA's health polling to keep the tunnel warm -- [ ] `client.rs` — endpoint + `MemoryLookup` seeded from the ticket; + *(verified by `tunnel::idle_pooled_conn_survives_quic_keepalive`: + 35 s fully-idle pooled HTTP/1.1 conn through default-config hermetic + endpoints, then a second request on the same conn succeeds — ~40 s + runtime by design, the slowest test in the crate)* +- [x] `client.rs` — endpoint + `MemoryLookup` seeded from the ticket; `TcpListener` accept loop; per conn: `open_bi()` on the current connection (re-dial with expo backoff on failure), write token, splice; status watch channel for CLI messaging -- [ ] Shutdown plumbing: `router.shutdown().await?` on the host (it closes + *(re-dial is owned by a supervisor task parked on `conn.closed()` — + backoff 250 ms → 5 s cap, per-attempt 10 s connect timeout; per-conn + handlers wait on the status watch, budget 30 s, then drop the TCP + conn so browser/health-supervisor retries stay cheap. Initial dial + failure is a `TunnelClient::bind` error by design — Phase 3 wants + "host unreachable" at join time, not a silent background retry)* +- [x] Shutdown plumbing: `router.shutdown().await?` on the host (it closes the endpoint itself; handle the returned `JoinError`); abort accept loop + close endpoint on the client + *(client shutdown awaits the aborted accept-loop task so the local + port is provably unbound before returning — asserted by + `tunnel::clean_shutdown`)* ## Phase 2 — `q2 preview --share` (host) diff --git a/crates/quarto-p2p/src/client.rs b/crates/quarto-p2p/src/client.rs index 1631b6c42..8924760a5 100644 --- a/crates/quarto-p2p/src/client.rs +++ b/crates/quarto-p2p/src/client.rs @@ -3,10 +3,34 @@ //! backoff on connection loss. use std::net::SocketAddr; +use std::sync::Arc; +use std::time::Duration; -use tokio::sync::watch; +use iroh::address_lookup::memory::MemoryLookup; +use iroh::endpoint::Connection; +use iroh::{Endpoint, EndpointAddr}; +use tokio::net::{TcpListener, TcpStream}; +use tokio::sync::{RwLock, watch}; +use tokio::task::JoinHandle; +use tokio::time::timeout; -use crate::{EndpointPreset, PreviewShareTicket, TunnelError, TunnelStatus}; +use crate::{ALPN, EndpointPreset, PreviewShareTicket, TOKEN_LEN, TunnelError, TunnelStatus}; + +/// Per-attempt cap on dialing the host (QUIC handshakes against a dead +/// UDP addr otherwise pend on retransmits for a long time). +const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); + +/// Exponential re-dial backoff bounds. +const REDIAL_BACKOFF_INITIAL: Duration = Duration::from_millis(250); +const REDIAL_BACKOFF_MAX: Duration = Duration::from_secs(5); + +/// How long an accepted local TCP connection waits for the tunnel to come +/// back before being dropped. Callers (browsers, the SPA's health +/// supervisor) retry with fresh connections, so failing one is cheap. +const STREAM_WAIT_TIMEOUT: Duration = Duration::from_secs(30); + +/// Poll grain while waiting for the supervisor to notice a dead connection. +const RETRY_POLL_INTERVAL: Duration = Duration::from_millis(100); /// Configuration for [`TunnelClient::bind`]. #[derive(Debug, Default, Clone, Copy)] @@ -27,26 +51,184 @@ impl TunnelClient { /// The initial dial happens here: an unreachable host is an error at /// bind time (clear CLI UX), not a background retry. pub async fn bind( - _cfg: TunnelClientConfig, - _ticket: PreviewShareTicket, - _local: SocketAddr, + cfg: TunnelClientConfig, + ticket: PreviewShareTicket, + local: SocketAddr, ) -> Result<(SocketAddr, TunnelClientHandle), TunnelError> { - todo!("Phase 1 (bd-v8mwzpmi)") + // Seed a MemoryLookup with the ticket's addresses so re-dials + // re-resolve without n0 infrastructure. + let lookup = MemoryLookup::new(); + lookup.add_endpoint_info(ticket.addr.clone()); + let endpoint = + crate::bind_endpoint(cfg.preset, None, None, |b| b.address_lookup(lookup.clone())) + .await?; + + let conn = timeout(CONNECT_TIMEOUT, endpoint.connect(ticket.addr.clone(), ALPN)) + .await + .map_err(|_| TunnelError::Connect("timed out dialing the share host".into()))? + .map_err(|e| TunnelError::Connect(Box::new(e)))?; + + let listener = TcpListener::bind(local).await.map_err(TunnelError::Proxy)?; + let local_addr = listener.local_addr().map_err(TunnelError::Proxy)?; + + let (status_tx, status_rx) = watch::channel(TunnelStatus::Connected); + let shared = Arc::new(Shared { + endpoint: endpoint.clone(), + remote: ticket.addr, + token: ticket.token, + conn: RwLock::new(conn), + status_tx, + status_rx: status_rx.clone(), + }); + + let supervisor = tokio::spawn(supervise_connection(shared.clone())); + let acceptor = tokio::spawn(accept_loop(listener, shared)); + + Ok(( + local_addr, + TunnelClientHandle { + endpoint, + supervisor, + acceptor, + status_rx, + }, + )) } } /// Handle to a running [`TunnelClient`]. #[derive(Debug)] -pub struct TunnelClientHandle {} +pub struct TunnelClientHandle { + endpoint: Endpoint, + supervisor: JoinHandle<()>, + acceptor: JoinHandle<()>, + status_rx: watch::Receiver, +} impl TunnelClientHandle { /// Watch channel for CLI messaging ("connected", "reconnecting…"). pub fn status(&self) -> watch::Receiver { - todo!("Phase 1 (bd-v8mwzpmi)") + self.status_rx.clone() } - /// Abort the accept loop and close the endpoint. + /// Abort the accept loop (unbinding the local port) and close the + /// endpoint gracefully. pub async fn shutdown(self) -> Result<(), TunnelError> { - todo!("Phase 1 (bd-v8mwzpmi)") + self.acceptor.abort(); + self.supervisor.abort(); + // Await the aborted tasks so the listener is guaranteed dropped + // (port unbound) before we return. + let _ = self.acceptor.await; + let _ = self.supervisor.await; + self.endpoint.close().await; + Ok(()) + } +} + +/// Shared state between the accept loop, per-connection tasks, and the +/// connection supervisor. +struct Shared { + endpoint: Endpoint, + remote: EndpointAddr, + token: [u8; TOKEN_LEN], + conn: RwLock, + status_tx: watch::Sender, + status_rx: watch::Receiver, +} + +/// Watches the current connection for death and re-dials with exponential +/// backoff, updating the status channel around the outage. +async fn supervise_connection(shared: Arc) { + loop { + let conn = shared.conn.read().await.clone(); + let reason = conn.closed().await; + tracing::info!(?reason, "preview tunnel: connection lost; re-dialing"); + shared.status_tx.send_replace(TunnelStatus::Reconnecting); + + let mut delay = REDIAL_BACKOFF_INITIAL; + loop { + match timeout( + CONNECT_TIMEOUT, + shared.endpoint.connect(shared.remote.clone(), ALPN), + ) + .await + { + Ok(Ok(new_conn)) => { + *shared.conn.write().await = new_conn; + shared.status_tx.send_replace(TunnelStatus::Connected); + tracing::info!("preview tunnel: reconnected"); + break; + } + Ok(Err(err)) => tracing::debug!(%err, "preview tunnel: re-dial failed"), + Err(_) => tracing::debug!("preview tunnel: re-dial timed out"), + } + tokio::time::sleep(delay).await; + delay = (delay * 2).min(REDIAL_BACKOFF_MAX); + } + } +} + +async fn accept_loop(listener: TcpListener, shared: Arc) { + loop { + match listener.accept().await { + Ok((tcp, _peer)) => { + tokio::spawn(handle_local_conn(tcp, shared.clone())); + } + Err(err) => { + tracing::warn!(%err, "preview tunnel: local proxy accept failed"); + tokio::time::sleep(Duration::from_millis(50)).await; + } + } + } +} + +/// One local TCP connection = one token-prefixed QUIC bi-stream. +async fn handle_local_conn(mut tcp: TcpStream, shared: Arc) { + let deadline = tokio::time::Instant::now() + STREAM_WAIT_TIMEOUT; + let (send, recv) = loop { + let conn = shared.conn.read().await.clone(); + match conn.open_bi().await { + Ok(pair) => break pair, + Err(err) => { + tracing::debug!(%err, "preview tunnel: open_bi failed; waiting for reconnect"); + // The supervisor notices the dead connection via + // `closed()` and re-dials; wait for it (with a poll grain + // in case it has not flipped the status yet), then retry + // on the — possibly swapped — connection. + tokio::time::sleep(RETRY_POLL_INTERVAL).await; + let Some(remaining) = deadline.checked_duration_since(tokio::time::Instant::now()) + else { + return; // budget exhausted; drop the TCP conn + }; + let mut status = shared.status_rx.clone(); + if timeout( + remaining, + status.wait_for(|s| *s == TunnelStatus::Connected), + ) + .await + .is_err() + { + return; + } + } + } + }; + + let mut send = send; + // Token prefix: authenticates the stream and satisfies iroh's + // write-first rule (the peer's accept_bi does not wake until bytes + // flow). Payload bytes pipeline right behind it — no extra RTT. + if send.write_all(&shared.token).await.is_err() { + return; + } + + let mut quic = tokio::io::join(recv, send); + match tokio::io::copy_bidirectional(&mut tcp, &mut quic).await { + Ok((to_host, from_host)) => { + tracing::debug!(to_host, from_host, "preview tunnel: local conn closed"); + } + Err(err) => { + tracing::debug!(%err, "preview tunnel: local conn ended with error"); + } } } diff --git a/crates/quarto-p2p/src/host.rs b/crates/quarto-p2p/src/host.rs index 264327249..fbcbdf2d3 100644 --- a/crates/quarto-p2p/src/host.rs +++ b/crates/quarto-p2p/src/host.rs @@ -3,10 +3,33 @@ use std::fmt; use std::net::SocketAddr; +use std::time::Duration; -use iroh::SecretKey; +use iroh::endpoint::{Connection, RecvStream, SendStream, VarInt}; +use iroh::protocol::{AcceptError, ProtocolHandler, Router}; +use iroh::{Endpoint, EndpointAddr, SecretKey, Watcher}; +use subtle::ConstantTimeEq; +use tokio::net::TcpStream; -use crate::{EndpointPreset, PreviewShareTicket, TOKEN_LEN, TunnelError}; +use crate::{ALPN, EndpointPreset, PreviewShareTicket, TOKEN_LEN, TunnelError}; + +/// How long a freshly accepted stream may take to present its token. +const TOKEN_READ_TIMEOUT: Duration = Duration::from_secs(10); + +/// How long to wait for the endpoint to come "online" (relay contact) with +/// the production preset before proceeding without one. Matches iroh's +/// (private) net-report budget; do NOT use `iroh::NET_REPORT_TIMEOUT`, +/// which is a bare `u64` docs constant, not a `Duration`. +const ONLINE_TIMEOUT: Duration = Duration::from_secs(10); + +/// How long to wait for a freshly bound endpoint to report at least one +/// dialable address. +const ADDR_TIMEOUT: Duration = Duration::from_secs(10); + +/// QUIC application error code for a stream that failed token auth. +const ERROR_CODE_UNAUTHORIZED: u32 = 1; +/// QUIC application error code for "the local target refused a connection". +const ERROR_CODE_TARGET_UNAVAILABLE: u32 = 2; /// Configuration for [`TunnelHost::spawn`]. /// @@ -50,20 +73,145 @@ impl TunnelHost { /// (the loopback-bound preview server), and return the join ticket /// plus a shutdown handle. pub async fn spawn( - _cfg: TunnelHostConfig, - _target: SocketAddr, + cfg: TunnelHostConfig, + target: SocketAddr, ) -> Result<(PreviewShareTicket, TunnelHostHandle), TunnelError> { - todo!("Phase 1 (bd-v8mwzpmi)") + let token = cfg.token.unwrap_or_else(rand::random); + let endpoint = + crate::bind_endpoint(cfg.preset, cfg.secret_key, cfg.bind_addr, |b| b).await?; + + // With relays in play, only a completed net-report makes the relay + // URL part of `endpoint.addr()`. `online()` pends forever with no + // relay reachable, so cap it and degrade to direct/LAN-only. + if cfg.preset == EndpointPreset::N0 + && tokio::time::timeout(ONLINE_TIMEOUT, endpoint.online()) + .await + .is_err() + { + tracing::warn!( + "iroh relay unreachable after {}s — ticket will carry direct/LAN addresses only", + ONLINE_TIMEOUT.as_secs() + ); + } + + let addr = dialable_addr(&endpoint).await?; + let router = Router::builder(endpoint) + .accept(ALPN, TunnelProtocol { token, target }) + .spawn(); + + let ticket = PreviewShareTicket { addr, token }; + Ok((ticket, TunnelHostHandle { router })) + } +} + +/// Waits until the endpoint reports at least one dialable transport addr. +async fn dialable_addr(endpoint: &Endpoint) -> Result { + let mut watcher = endpoint.watch_addr(); + let deadline = tokio::time::Instant::now() + ADDR_TIMEOUT; + loop { + let addr = watcher.get(); + if !addr.addrs.is_empty() { + return Ok(addr); + } + let Some(remaining) = deadline.checked_duration_since(tokio::time::Instant::now()) else { + return Err(TunnelError::NoAddress); + }; + match tokio::time::timeout(remaining, watcher.updated()).await { + Ok(Ok(_)) => continue, + Ok(Err(_)) | Err(_) => return Err(TunnelError::NoAddress), + } } } /// Handle to a running [`TunnelHost`]. #[derive(Debug)] -pub struct TunnelHostHandle {} +pub struct TunnelHostHandle { + router: Router, +} impl TunnelHostHandle { - /// Graceful shutdown: `Router::shutdown` closes the endpoint itself. + /// Graceful shutdown: `Router::shutdown` closes the endpoint itself + /// (a trailing `Endpoint::close` would be an idempotent no-op). pub async fn shutdown(self) -> Result<(), TunnelError> { - todo!("Phase 1 (bd-v8mwzpmi)") + self.router + .shutdown() + .await + .map_err(|e| TunnelError::Shutdown(Box::new(e))) + } +} + +/// The `q2/preview-tunnel/0` protocol: per connection, accept bi-streams +/// forever; per stream, check the token prefix and splice onto the target. +#[derive(Debug, Clone)] +struct TunnelProtocol { + token: [u8; TOKEN_LEN], + target: SocketAddr, +} + +impl ProtocolHandler for TunnelProtocol { + async fn accept(&self, connection: Connection) -> Result<(), AcceptError> { + // Log joining peers for auditability (security model). + let remote = connection.remote_id().fmt_short(); + tracing::info!(%remote, "preview tunnel: peer connected"); + // The loop ends when accept_bi errors: peer disconnected or we + // shut down; either way this per-connection task is done. + while let Ok((send, recv)) = connection.accept_bi().await { + let proto = self.clone(); + let connection = connection.clone(); + tokio::spawn(async move { proto.handle_stream(connection, send, recv).await }); + } + tracing::info!(%remote, "preview tunnel: peer disconnected"); + Ok(()) + } +} + +impl TunnelProtocol { + async fn handle_stream( + &self, + connection: Connection, + mut send: SendStream, + mut recv: RecvStream, + ) { + let remote = connection.remote_id().fmt_short(); + + let mut presented = [0u8; TOKEN_LEN]; + let authorized = + match tokio::time::timeout(TOKEN_READ_TIMEOUT, recv.read_exact(&mut presented)).await { + Ok(Ok(())) => bool::from(presented.ct_eq(&self.token)), + // Short read (stream finished early) or timeout: unauthorized. + Ok(Err(_)) | Err(_) => false, + }; + if !authorized { + tracing::warn!(%remote, "preview tunnel: bad or missing token; dropping connection"); + let _ = send.reset(VarInt::from_u32(ERROR_CODE_UNAUTHORIZED)); + connection.close(VarInt::from_u32(ERROR_CODE_UNAUTHORIZED), b"unauthorized"); + return; + } + + let mut tcp = match TcpStream::connect(self.target).await { + Ok(tcp) => tcp, + Err(err) => { + tracing::warn!( + %remote, target = %self.target, %err, + "preview tunnel: local target refused connection" + ); + let _ = send.reset(VarInt::from_u32(ERROR_CODE_TARGET_UNAVAILABLE)); + return; + } + }; + + // Terminated splice. `copy_bidirectional` propagates read-EOF as a + // write-shutdown on the opposite side: QUIC FIN → TCP FIN via + // `TcpStream::poll_shutdown`, TCP FIN → QUIC FIN via + // `SendStream::poll_shutdown` (which calls `finish()`). + let mut quic = tokio::io::join(recv, send); + match tokio::io::copy_bidirectional(&mut quic, &mut tcp).await { + Ok((to_target, from_target)) => { + tracing::debug!(%remote, to_target, from_target, "preview tunnel: stream closed"); + } + Err(err) => { + tracing::debug!(%remote, %err, "preview tunnel: stream ended with error"); + } + } } } diff --git a/crates/quarto-p2p/src/lib.rs b/crates/quarto-p2p/src/lib.rs index 0a271054e..8aac6f5a4 100644 --- a/crates/quarto-p2p/src/lib.rs +++ b/crates/quarto-p2p/src/lib.rs @@ -54,6 +54,48 @@ pub enum TunnelStatus { pub(crate) type BoxedError = Box; +/// Bind an iroh endpoint for the given preset (shared by host and client). +/// +/// `HermeticLoopback` builds a `presets::Minimal` endpoint bound to loopback +/// only, with relays explicitly disabled (Minimal already defaults to +/// `RelayMode::Disabled`; the explicit setting documents the hermetic +/// posture). `configure` lets callers add builder options (e.g. the +/// client's `MemoryLookup`). +pub(crate) async fn bind_endpoint( + preset: EndpointPreset, + secret_key: Option, + bind_addr: Option, + configure: impl FnOnce(iroh::endpoint::Builder) -> iroh::endpoint::Builder, +) -> Result { + let mut builder = match preset { + EndpointPreset::N0 => iroh::Endpoint::builder(iroh::endpoint::presets::N0), + EndpointPreset::HermeticLoopback => { + iroh::Endpoint::builder(iroh::endpoint::presets::Minimal) + .relay_mode(iroh::RelayMode::Disabled) + .clear_ip_transports() + } + }; + let bind_addr = match (preset, bind_addr) { + (_, Some(addr)) => Some(addr), + (EndpointPreset::HermeticLoopback, None) => { + Some("127.0.0.1:0".parse().expect("loopback socket addr")) + } + (EndpointPreset::N0, None) => None, + }; + if let Some(addr) = bind_addr { + builder = builder + .bind_addr(addr) + .map_err(|e| TunnelError::Bind(Box::new(e)))?; + } + if let Some(secret_key) = secret_key { + builder = builder.secret_key(secret_key); + } + configure(builder) + .bind() + .await + .map_err(|e| TunnelError::Bind(Box::new(e))) +} + /// Errors from the tunnel API. #[derive(Debug, thiserror::Error)] pub enum TunnelError { diff --git a/crates/quarto-p2p/src/ticket.rs b/crates/quarto-p2p/src/ticket.rs index 07ac81f10..b6d4266c1 100644 --- a/crates/quarto-p2p/src/ticket.rs +++ b/crates/quarto-p2p/src/ticket.rs @@ -1,10 +1,12 @@ //! The `q2preview…` join string: host `EndpointAddr` + session token. +use std::collections::BTreeSet; use std::fmt; use std::str::FromStr; -use iroh::EndpointAddr; +use iroh::{EndpointAddr, EndpointId, TransportAddr}; use iroh_tickets::{ParseError, Ticket}; +use serde::{Deserialize, Serialize}; use crate::TOKEN_LEN; @@ -22,34 +24,62 @@ pub struct PreviewShareTicket { pub token: [u8; TOKEN_LEN], } +/// Wire format, following the `iroh-tickets` versioned-enum convention: +/// the postcard enum tag doubles as the body version, alongside the KIND +/// string that tags the protocol itself. +#[derive(Serialize, Deserialize)] +enum TicketWireFormat { + Variant1(Variant1PreviewShareTicket), +} + +#[derive(Serialize, Deserialize)] +struct Variant1PreviewShareTicket { + id: EndpointId, + addrs: BTreeSet, + token: [u8; TOKEN_LEN], +} + impl Ticket for PreviewShareTicket { const KIND: &'static str = "q2preview"; fn encode_bytes(&self) -> Vec { - todo!("Phase 1 (bd-v8mwzpmi)") + let data = TicketWireFormat::Variant1(Variant1PreviewShareTicket { + id: self.addr.id, + addrs: self.addr.addrs.clone(), + token: self.token, + }); + postcard::to_stdvec(&data).expect("postcard serialization failed") } - fn decode_bytes(_bytes: &[u8]) -> Result { - todo!("Phase 1 (bd-v8mwzpmi)") + fn decode_bytes(bytes: &[u8]) -> Result { + let data: TicketWireFormat = postcard::from_bytes(bytes)?; + let TicketWireFormat::Variant1(Variant1PreviewShareTicket { id, addrs, token }) = data; + Ok(Self { + addr: EndpointAddr { id, addrs }, + token, + }) } } impl fmt::Display for PreviewShareTicket { - fn fmt(&self, _f: &mut fmt::Formatter<'_>) -> fmt::Result { - todo!("Phase 1 (bd-v8mwzpmi)") + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.encode_string()) } } impl FromStr for PreviewShareTicket { type Err = ParseError; - fn from_str(_s: &str) -> Result { - todo!("Phase 1 (bd-v8mwzpmi)") + fn from_str(s: &str) -> Result { + Self::decode_string(s) } } impl fmt::Debug for PreviewShareTicket { - fn fmt(&self, _f: &mut fmt::Formatter<'_>) -> fmt::Result { - todo!("Phase 1 (bd-v8mwzpmi)") + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("PreviewShareTicket") + .field("addr", &self.addr) + .field("token", &"[redacted]") + .finish() } } diff --git a/crates/quarto-p2p/tests/integration/tunnel.rs b/crates/quarto-p2p/tests/integration/tunnel.rs index e5a68866a..11e0732f6 100644 --- a/crates/quarto-p2p/tests/integration/tunnel.rs +++ b/crates/quarto-p2p/tests/integration/tunnel.rs @@ -281,11 +281,11 @@ async fn client_redials_after_connection_loss() { let deadline = Instant::now() + Duration::from_secs(30); let mut recovered = false; while Instant::now() < deadline { - if let Ok(response) = try_http_get_close(local, "/").await { - if response.contains("redial-ok") { - recovered = true; - break; - } + if let Ok(response) = try_http_get_close(local, "/").await + && response.contains("redial-ok") + { + recovered = true; + break; } tokio::time::sleep(Duration::from_millis(500)).await; } From 4a0fe6b1298afe558a10aaab46308e5fddf32350 Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:49:58 +0100 Subject: [PATCH 05/22] =?UTF-8?q?feat(preview):=20Phase=202=20=E2=80=94=20?= =?UTF-8?q?q2=20preview=20--share=20host=20(bd-jhvkwosw)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Host side of live share: `q2 preview --share` spawns a quarto-p2p TunnelHost in front of the preview server's loopback port and prints a capability banner + ready-to-paste `q2 preview --join q2preview…` line (join line last, bare, for copy-paste through terminal wrapping). - CLI: `--share` flag; `--join ` declared hidden with conflicts_with("share") + a runtime Phase 3 bail; first clap parse tests for the q2 CLI (try_parse_from harness in main.rs). - quarto-preview: new `share` module — start_share_session() + format_share_banner() + share_target(); PreviewConfig::share; session spawned in run_with_on_ready before the server starts and shut down after run_server_with returns (before the CLI's TempDir drop). Banner carries the direct/LAN-only notice when the ticket has no relay addr (quarto-p2p's tracing::warn is invisible at the default `quarto=warn` filter). - quarto-p2p: PreviewShareTicket::has_relay_addr(); tunnel-client example (reference guest until Phase 3's real --join). TDD: CLI tests failed first via E0026 missing-field compile errors; share-glue tests 4/4 failed on todo!() stubs, then went green. Verified: cargo nextest run --workspace 10883 passed; cargo xtask verify --skip-hub-build all green; cargo tree -i iroh from wasm-quarto-hub-client still fails (WASM closure clean). Recorded end-to-end run (host --share + example guest + Playwright browser: /health identical through tunnel, render 1.47s, live edit propagated 1.07s, SIGINT exit 0) in the plan. Plan: claude-notes/plans/2026-08-03-q2-preview-live-share-iroh.md --- Cargo.lock | 1 + .../2026-08-03-q2-preview-live-share-iroh.md | 96 +++++++++- crates/quarto-p2p/examples/tunnel-client.rs | 41 +++++ crates/quarto-p2p/src/ticket.rs | 15 ++ crates/quarto-p2p/tests/integration/ticket.rs | 19 ++ crates/quarto-preview/Cargo.toml | 3 + crates/quarto-preview/src/lib.rs | 51 +++++- crates/quarto-preview/src/share.rs | 124 +++++++++++++ .../quarto-preview/tests/integration/boot.rs | 1 + .../tests/integration/config_endpoint.rs | 1 + .../diagnostics_capture_failure.rs | 1 + .../tests/integration/diagnostics_endpoint.rs | 1 + .../tests/integration/eager_capture.rs | 2 + .../quarto-preview/tests/integration/main.rs | 1 + .../tests/integration/render_scripts_boot.rs | 1 + .../quarto-preview/tests/integration/share.rs | 168 ++++++++++++++++++ .../tests/integration/staleness.rs | 1 + crates/quarto/src/commands/preview.rs | 5 + crates/quarto/src/main.rs | 111 ++++++++++-- 19 files changed, 624 insertions(+), 19 deletions(-) create mode 100644 crates/quarto-p2p/examples/tunnel-client.rs create mode 100644 crates/quarto-preview/src/share.rs create mode 100644 crates/quarto-preview/tests/integration/share.rs diff --git a/Cargo.lock b/Cargo.lock index 72bbe3419..08d8e4184 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5263,6 +5263,7 @@ dependencies = [ "quarto-core", "quarto-error-reporting", "quarto-hub", + "quarto-p2p", "quarto-pandoc-types", "quarto-source-map", "quarto-system-runtime", diff --git a/claude-notes/plans/2026-08-03-q2-preview-live-share-iroh.md b/claude-notes/plans/2026-08-03-q2-preview-live-share-iroh.md index aff6bc02d..f2bb8a5df 100644 --- a/claude-notes/plans/2026-08-03-q2-preview-live-share-iroh.md +++ b/claude-notes/plans/2026-08-03-q2-preview-live-share-iroh.md @@ -745,24 +745,45 @@ from `wasm-quarto-hub-client` still fails — output inspected for all)* ## Phase 2 — `q2 preview --share` (host) +*(implemented 2026-08-05, bd-jhvkwosw; tests landed first and were +observed failing — CLI tests via E0026 missing-field compile errors (the +expected failure mode for a structural clap addition), share-glue tests +4/4 FAIL at runtime on `todo!()` stubs, output inspected — then went +green with the implementation)* + **Tests first:** -- [ ] CLI: `--share` parses; `--share --join x` rejected. **New tests, not an +- [x] CLI: `--share` parses; `--share --join x` rejected. **New tests, not an extension** — `crates/quarto` has no clap parse tests today (`preview.rs:625-647` are boot-URL formatting tests; the one existing exclusion is a runtime bail at `preview.rs:70`), so build the small `try_parse_from` harness this plan's conflict matrices need -- [ ] `quarto-preview` unit: share glue produces a ticket whose tunnel target + *(`cli_parse_tests` in `crates/quarto/src/main.rs`: parses, defaults + off, composes with `--allow-edit`, conflicts with `--join` — the + conflict asserted as `ErrorKind::ArgumentConflict`, so it pins a real + `conflicts_with`, not an unknown-arg rejection)* +- [x] `quarto-preview` unit: share glue produces a ticket whose tunnel target is `127.0.0.1:{config.port}` (the port is resolved CLI-side before the server starts, `preview.rs:114-117` — `on_ready` does *not* carry it); ticket line printed via an injected writer/callback — do not scrape stdout + *(`crates/quarto-preview/tests/integration/share.rs`, hermetic-iroh: + the glue test fetches a marker through a `TunnelClient` bound to the + minted ticket, proving the target; the banner arrives via an injected + closure. Extra banner tests: capability wording per `--allow-edit`, + direct/LAN-only notice when the ticket has no relay addr, join line + always last (copy-paste contract))* **Implementation:** -- [ ] `PreviewArgs::share` + clap flag (`crates/quarto/src/main.rs` Preview +- [x] `PreviewArgs::share` + clap flag (`crates/quarto/src/main.rs` Preview variant) → `PreviewConfig::share` (`crates/quarto-preview/src/lib.rs`) -- [ ] `quarto-preview` → `quarto-p2p` dep; when sharing: generate token, bind + *(also declared `--join ` in clap now — hidden + (`hide = true`) with `conflicts_with = "share"` and a runtime + "not implemented yet (Phase 3)" bail — because the conflict test + needs the arg to exist; Phase 3 unhides it, implements the guest + path, and adds the full conflict matrix)* +- [x] `quarto-preview` → `quarto-p2p` dep; when sharing: generate token, bind endpoint (`presets::N0`), `timeout(Duration::from_secs(10), online())` (on timeout: proceed, warn "relay unreachable — direct/LAN connections only"), `TunnelHost::spawn` targeting `config.port`. **Print timing:** @@ -793,13 +814,76 @@ from `wasm-quarto-hub-client` still fails — output inspected for all)* nothing after it, so a triple-click / drag copy survives terminal wrapping; the end-to-end check below must include copy-pasting the wrapped line from a real terminal -- [ ] Ctrl-C: tunnel shutdown joined into the existing graceful-shutdown path + *(done: `crates/quarto-preview/src/share.rs` — + `start_share_session(TunnelHostConfig, host, port, allow_edit, + announce)` → `ShareSession { ticket, handle }` + + `format_share_banner`; called from `run_with_on_ready` before the + server starts (with a `port != 0` guard — library callers must + pre-resolve like the CLI does). Two deviations-with-reasons from the + sketch: (1) the banner's relay-unreachable warning is driven by + inspecting the minted ticket via a new + `PreviewShareTicket::has_relay_addr()` — quarto-p2p's + `tracing::warn!` is invisible at the CLI's default `quarto=warn` + filter, so the banner is the user-visible signal; (2) the tunnel + target is `share_target(host, port)` rather than hardcoded + `127.0.0.1` so `--share` still works when `--host` binds a concrete + non-loopback interface — unspecified binds (`0.0.0.0`/`::`) and + hostnames still map to loopback)* +- [x] Ctrl-C: tunnel shutdown joined into the existing graceful-shutdown path (before the `TempDir` drop) -- [ ] **End-to-end (mandatory, record invocation + output here):** two + *(in `run_with_on_ready`: `ShareSession::shutdown()` runs after + `run_server_with` returns — i.e. after the hub's final filesystem + sync — and before control returns to the CLI where the ephemeral + `TempDir` drops; verified live in the e2e below: SIGINT → graceful + shutdown logs → `EXIT=0`)* +- [x] **End-to-end (mandatory, record invocation + output here):** two terminals on one machine — host `--share` in a fixture project, guest `--join`; `curl http://127.0.0.1:/health` shows the host's `index_document_id`; browser on the guest port renders the document; live edit on host propagates + *(executed 2026-08-05 — see "Phase 2 end-to-end record" below; the + guest side used `cargo run -p quarto-p2p --example tunnel-client` + since `--join` itself is Phase 3)* + +### Phase 2 end-to-end record (2026-08-05) + +All output inspected; the guest was the new `tunnel-client` example +(`crates/quarto-p2p/examples/tunnel-client.rs`) because the real `--join` +lands in Phase 3 — the tunnel path exercised (ticket parse → +`TunnelClient::bind` → local proxy) is exactly what Phase 3 will wrap. + +- **Host** (fixture project: `_quarto.yml` + `index.qmd` + `about.qmd`): + `q2 preview --share --no-browser --port 9377` printed the + boot URL, then the banner: + + ``` + Sharing this preview session (end-to-end encrypted via iroh). + Anyone with the join string below can VIEW the project and RE-RUN its + code on this machine: + + q2 preview --join q2previewadtdnwynvuwfau3ybfdlxih7yyexrw5xms6ew6dnoix7g3y… + ``` + + (224-char ticket, within the plan's 173–235 estimate; no direct/LAN + warning — the n0 relay was reachable.) +- **Guest:** `tunnel-client 9280` → "joined shared preview + session: http://127.0.0.1:9280/", status `Connected`. The ticket was + copy-pasted from the host's captured stdout and parsed round-trip; + the interactive triple-click-on-a-wrapped-terminal-line check still + wants a human eyeball (noted for the Phase 3 e2e, which a user drives). +- **`/health` through the tunnel** returned the byte-identical payload to + direct, including `"index_document_id":"2GQkn7ADdeaLnaME5mQBo6eFnJvi"` + and `"qmd_file_count":2`. +- **Browser (Playwright Chromium 1223** at `http://127.0.0.1:9280/?page=index.qmd`, + frames scanned per the Gate 0 iframe finding): document rendered + through the tunnel in **1.47 s**; screenshot inspected (`MARKER-0` + visible). +- **Live edit:** host-side `MARKER-0`→`MARKER-1` write propagated to the + guest browser in **1.07 s**; post-edit screenshot inspected (rendered + text shows `MARKER-1`). +- **Ctrl-C:** SIGINT to the host → "Received Ctrl-C, initiating graceful + shutdown…" → final filesystem sync (3 docs, 0 errors) → process + `EXIT=0`; the guest's status watch flipped `Connected → Reconnecting`. ## Phase 3 — `q2 preview --join ` (guest) diff --git a/crates/quarto-p2p/examples/tunnel-client.rs b/crates/quarto-p2p/examples/tunnel-client.rs new file mode 100644 index 000000000..f8855c51d --- /dev/null +++ b/crates/quarto-p2p/examples/tunnel-client.rs @@ -0,0 +1,41 @@ +//! Minimal guest-side driver for `q2 preview --share` sessions. +//! +//! Until `q2 preview --join` lands (live-share plan Phase 3, +//! bd-6y0p1bne), this example is the reference guest: it joins a shared +//! preview session and serves it on a local loopback port. +//! +//! ```text +//! cargo run -p quarto-p2p --example tunnel-client -- [local-port] +//! ``` + +use quarto_p2p::{PreviewShareTicket, TunnelClient, TunnelClientConfig}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let mut args = std::env::args().skip(1); + let ticket: PreviewShareTicket = args + .next() + .ok_or("usage: tunnel-client [local-port]")? + .parse()?; + let port: u16 = match args.next() { + Some(p) => p.parse()?, + None => 0, + }; + + let (local, handle) = TunnelClient::bind( + TunnelClientConfig::default(), + ticket, + ([127, 0, 0, 1], port).into(), + ) + .await?; + println!("joined shared preview session: http://{local}/"); + + // Report status transitions until Ctrl-C. + let mut status = handle.status(); + loop { + println!("tunnel status: {:?}", *status.borrow_and_update()); + if status.changed().await.is_err() { + return Ok(()); + } + } +} diff --git a/crates/quarto-p2p/src/ticket.rs b/crates/quarto-p2p/src/ticket.rs index b6d4266c1..0d33b14c3 100644 --- a/crates/quarto-p2p/src/ticket.rs +++ b/crates/quarto-p2p/src/ticket.rs @@ -39,6 +39,21 @@ struct Variant1PreviewShareTicket { token: [u8; TOKEN_LEN], } +impl PreviewShareTicket { + /// Whether the host published a relay address into this ticket. + /// + /// `false` means the host's endpoint never came online with a relay + /// (or runs a hermetic test preset): guests can join over direct/LAN + /// paths only. Exposed so consumers can report reachability without + /// matching on iroh's `TransportAddr` themselves. + pub fn has_relay_addr(&self) -> bool { + self.addr + .addrs + .iter() + .any(|a| matches!(a, TransportAddr::Relay(_))) + } +} + impl Ticket for PreviewShareTicket { const KIND: &'static str = "q2preview"; diff --git a/crates/quarto-p2p/tests/integration/ticket.rs b/crates/quarto-p2p/tests/integration/ticket.rs index 540fe54b6..9c9c8ba2a 100644 --- a/crates/quarto-p2p/tests/integration/ticket.rs +++ b/crates/quarto-p2p/tests/integration/ticket.rs @@ -82,6 +82,25 @@ fn rejects_garbage_and_foreign_kinds() { ); } +/// Phase 2 (bd-jhvkwosw): the share glue inspects the freshly minted +/// ticket to tell the user whether guests can join via relay or only +/// direct/LAN — without quarto-preview matching on iroh types itself. +#[test] +fn has_relay_addr_reflects_transport_addrs() { + let with_relay = PreviewShareTicket { + addr: sample_addr(), + token: sample_token(), + }; + assert!(with_relay.has_relay_addr()); + + let id = SecretKey::from_bytes(&[9u8; 32]).public(); + let direct_only = PreviewShareTicket { + addr: EndpointAddr::from_parts(id, [TransportAddr::Ip("127.0.0.1:4433".parse().unwrap())]), + token: sample_token(), + }; + assert!(!direct_only.has_relay_addr()); +} + #[test] fn debug_redacts_token() { let ticket = PreviewShareTicket { diff --git a/crates/quarto-preview/Cargo.toml b/crates/quarto-preview/Cargo.toml index 68911b7d9..3d57a39a0 100644 --- a/crates/quarto-preview/Cargo.toml +++ b/crates/quarto-preview/Cargo.toml @@ -28,6 +28,9 @@ samod = { version = "0.12.3", git = "https://github.com/quarto-dev/samod.git", b quarto-core.workspace = true quarto-error-reporting = { workspace = true, features = ["json"] } quarto-hub.workspace = true +# Live-share tunnel host (`--share`, plan Phase 2). Native-only; keeps +# all iroh usage behind quarto-p2p's API. +quarto-p2p.workspace = true quarto-pandoc-types.workspace = true quarto-source-map.workspace = true quarto-system-runtime.workspace = true diff --git a/crates/quarto-preview/src/lib.rs b/crates/quarto-preview/src/lib.rs index 9e77c31e6..90c998263 100644 --- a/crates/quarto-preview/src/lib.rs +++ b/crates/quarto-preview/src/lib.rs @@ -29,6 +29,7 @@ pub mod config; pub mod deps; pub mod diagnostics; pub mod re_execute; +pub mod share; pub use config::EnginePolicy; @@ -122,6 +123,14 @@ pub struct PreviewConfig { /// document changes from *any* connected client can never be /// written back to the user's files. pub allow_edit: bool, + /// Share this preview session over an end-to-end encrypted iroh + /// tunnel (`--share`, bd-jhvkwosw). When set, [`run`] spawns a + /// [`share::ShareSession`] targeting `host:port` before the server + /// starts and prints the join banner; the tunnel is shut down after + /// the server exits (before the CLI drops its ephemeral `TempDir`). + /// Requires a pre-resolved (non-zero) `port` — the CLI probes one + /// before calling in. + pub share: bool, } /// Run the preview server. Returns when the server is shut down (ctrl-c @@ -282,15 +291,51 @@ where }); }); - server::run_server_with( + // bd-jhvkwosw (live-share Phase 2): when sharing, spawn the tunnel + // host *before* the server starts — the ticket's inputs (host, port, + // token, endpoint addr) all exist already, and printing ahead of the + // listener bind matches the CLI boot-URL print's contract (a + // too-fast guest just retries via its health supervisor). + let share_session = if config.share { + anyhow::ensure!( + config.port != 0, + "--share requires a resolved port; the CLI probes a free one before starting \ + the server, library callers must do the same" + ); + let session = share::start_share_session( + quarto_p2p::TunnelHostConfig::default(), + &config.host, + config.port, + config.allow_edit, + |banner| println!("\n{banner}\n"), + ) + .await + .context("starting the live-share tunnel")?; + Some(session) + } else { + None + }; + + let server_result = server::run_server_with( storage, hub_config, Some(extend_with_preview), Some(on_ready), Some(on_file_changed), ) - .await - .context("quarto-hub server failed")?; + .await; + + // bd-jhvkwosw: tunnel teardown joins the graceful-shutdown path — + // after the server (and its final filesystem sync) exits, before the + // CLI drops its ephemeral TempDir. Failure is logged, not fatal; + // the process is exiting either way. + if let Some(session) = share_session + && let Err(e) = session.shutdown().await + { + tracing::warn!(error = %e, "live-share tunnel shutdown failed"); + } + + server_result.context("quarto-hub server failed")?; Ok(()) } diff --git a/crates/quarto-preview/src/share.rs b/crates/quarto-preview/src/share.rs new file mode 100644 index 000000000..1ffb2c6e6 --- /dev/null +++ b/crates/quarto-preview/src/share.rs @@ -0,0 +1,124 @@ +//! `--share` glue (live-share plan Phase 2, bd-jhvkwosw): spawn the +//! quarto-p2p tunnel host in front of the preview server's loopback +//! port and announce the ready-to-paste join string. +//! +//! The tunnel is the *only* remote surface `--share` adds — the HTTP +//! port itself stays loopback-bound. Possession of the join string is +//! the capability, which is why the banner spells out exactly what it +//! grants (view + re-run, plus disk writes iff `--allow-edit`). + +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; + +use quarto_p2p::{PreviewShareTicket, TunnelError, TunnelHost, TunnelHostConfig, TunnelHostHandle}; + +/// A live share session: the join ticket plus the running tunnel host. +pub struct ShareSession { + /// The join ticket (host `EndpointAddr` + session token). Public so + /// callers/tests can render or re-derive the join string. + pub ticket: PreviewShareTicket, + handle: TunnelHostHandle, +} + +impl ShareSession { + /// Gracefully shut the tunnel host down (closes the iroh endpoint). + pub async fn shutdown(self) -> Result<(), TunnelError> { + self.handle.shutdown().await + } +} + +/// Spawn the tunnel host targeting the preview server at +/// `share_target(host, port)` and announce the join banner via +/// `announce` (production passes a `println!` shim; tests capture). +/// +/// The port must already be resolved — the CLI probes it before the +/// server starts, so the ticket exists (and prints) ahead of the first +/// accept; a too-fast guest just retries via its health supervisor. +/// +/// With the production n0 preset this awaits the endpoint's relay +/// contact (bounded inside [`TunnelHost::spawn`], ~10 s worst case); +/// when no relay was reachable the minted ticket carries direct/LAN +/// addresses only, and the banner says so — quarto-p2p's own +/// `tracing::warn!` is invisible at the CLI's default `quarto=warn` +/// filter, so the banner is the user-facing signal. +pub async fn start_share_session( + cfg: TunnelHostConfig, + host: &str, + port: u16, + allow_edit: bool, + announce: impl FnOnce(&str), +) -> Result { + let (ticket, handle) = TunnelHost::spawn(cfg, share_target(host, port)).await?; + let banner = format_share_banner(&ticket.to_string(), allow_edit, ticket.has_relay_addr()); + announce(&banner); + Ok(ShareSession { ticket, handle }) +} + +/// Render the share banner: capability warning + the bare +/// `q2 preview --join …` line (last, with nothing after it, so a +/// triple-click / drag copy survives terminal wrapping). +pub fn format_share_banner(join_string: &str, allow_edit: bool, relay_reachable: bool) -> String { + let mut banner = String::new(); + banner.push_str("Sharing this preview session (end-to-end encrypted via iroh).\n"); + if allow_edit { + banner.push_str( + "Anyone with the join string below can VIEW the project, RE-RUN its code\n\ + on this machine, and EDIT the project's files on disk (--allow-edit):\n", + ); + } else { + banner.push_str( + "Anyone with the join string below can VIEW the project and RE-RUN its\n\ + code on this machine:\n", + ); + } + if !relay_reachable { + banner.push_str( + "\nNote: no relay is reachable, so guests can join over direct/LAN\n\ + connections only.\n", + ); + } + banner.push('\n'); + banner.push_str(&format!("q2 preview --join {join_string}")); + banner +} + +/// Map the preview server's bind host to the tunnel's TCP target. +/// +/// The default (and the plan's stated posture) is `127.0.0.1:{port}`. +/// The other arms keep `--share` working when `--host` was also set: +/// an unspecified bind (`0.0.0.0` / `::`) is reachable via the +/// matching loopback, a concrete IP is its own target, and a hostname +/// (e.g. `localhost`) falls back to IPv4 loopback. +fn share_target(host: &str, port: u16) -> SocketAddr { + let ip = match host.parse::() { + Ok(ip) if ip.is_unspecified() => match ip { + IpAddr::V4(_) => IpAddr::V4(Ipv4Addr::LOCALHOST), + IpAddr::V6(_) => IpAddr::V6(Ipv6Addr::LOCALHOST), + }, + Ok(ip) => ip, + Err(_) => IpAddr::V4(Ipv4Addr::LOCALHOST), + }; + SocketAddr::new(ip, port) +} + +#[cfg(test)] +mod tests { + use super::share_target; + + #[test] + fn share_target_maps_bind_hosts_to_dialable_targets() { + for (host, expected) in [ + ("127.0.0.1", "127.0.0.1:7777"), + ("0.0.0.0", "127.0.0.1:7777"), + ("::", "[::1]:7777"), + ("::1", "[::1]:7777"), + ("192.168.1.5", "192.168.1.5:7777"), + ("localhost", "127.0.0.1:7777"), + ] { + assert_eq!( + share_target(host, 7777), + expected.parse().unwrap(), + "share_target({host:?})" + ); + } + } +} diff --git a/crates/quarto-preview/tests/integration/boot.rs b/crates/quarto-preview/tests/integration/boot.rs index 280300b0f..a245e5ab3 100644 --- a/crates/quarto-preview/tests/integration/boot.rs +++ b/crates/quarto-preview/tests/integration/boot.rs @@ -80,6 +80,7 @@ async fn boots_serves_spa_plus_hub_health() { resource_html_files: Vec::new(), cache_dir: None, allow_edit: false, + share: false, }; // Spawn the server. `run()` blocks until shutdown; we abort the diff --git a/crates/quarto-preview/tests/integration/config_endpoint.rs b/crates/quarto-preview/tests/integration/config_endpoint.rs index f9c5674e2..0d36e9148 100644 --- a/crates/quarto-preview/tests/integration/config_endpoint.rs +++ b/crates/quarto-preview/tests/integration/config_endpoint.rs @@ -58,6 +58,7 @@ async fn boot_server_for_test( resource_html_files: Vec::new(), cache_dir: None, allow_edit, + share: false, }; let (ready_tx, ready_rx) = oneshot::channel::>(); diff --git a/crates/quarto-preview/tests/integration/diagnostics_capture_failure.rs b/crates/quarto-preview/tests/integration/diagnostics_capture_failure.rs index d09cf55b8..7a935db97 100644 --- a/crates/quarto-preview/tests/integration/diagnostics_capture_failure.rs +++ b/crates/quarto-preview/tests/integration/diagnostics_capture_failure.rs @@ -79,6 +79,7 @@ async fn capture_failure_lands_in_sink() { resource_html_files: Vec::new(), cache_dir: None, allow_edit: false, + share: false, }; let (ready_tx, ready_rx) = oneshot::channel::>(); diff --git a/crates/quarto-preview/tests/integration/diagnostics_endpoint.rs b/crates/quarto-preview/tests/integration/diagnostics_endpoint.rs index 67be76db4..4b7b78597 100644 --- a/crates/quarto-preview/tests/integration/diagnostics_endpoint.rs +++ b/crates/quarto-preview/tests/integration/diagnostics_endpoint.rs @@ -61,6 +61,7 @@ async fn boot_server_for_test() -> ( resource_html_files: Vec::new(), cache_dir: None, allow_edit: false, + share: false, }; let (ready_tx, ready_rx) = oneshot::channel::>(); diff --git a/crates/quarto-preview/tests/integration/eager_capture.rs b/crates/quarto-preview/tests/integration/eager_capture.rs index 4da760b82..44b89eb9a 100644 --- a/crates/quarto-preview/tests/integration/eager_capture.rs +++ b/crates/quarto-preview/tests/integration/eager_capture.rs @@ -82,6 +82,7 @@ async fn eager_capture_populates_index_sidecar() { resource_html_files: Vec::new(), cache_dir: None, allow_edit: false, + share: false, }; let (ready_tx, ready_rx) = oneshot::channel::>(); @@ -197,6 +198,7 @@ async fn prose_only_doc_leaves_sidecar_empty() { resource_html_files: Vec::new(), cache_dir: None, allow_edit: false, + share: false, }; let (ready_tx, ready_rx) = oneshot::channel::>(); diff --git a/crates/quarto-preview/tests/integration/main.rs b/crates/quarto-preview/tests/integration/main.rs index 6b326eb68..002b4ee1b 100644 --- a/crates/quarto-preview/tests/integration/main.rs +++ b/crates/quarto-preview/tests/integration/main.rs @@ -8,6 +8,7 @@ pub mod diagnostics_capture_failure; pub mod diagnostics_endpoint; pub mod eager_capture; pub mod render_scripts_boot; +pub mod share; pub mod smoke; pub mod staleness; diff --git a/crates/quarto-preview/tests/integration/render_scripts_boot.rs b/crates/quarto-preview/tests/integration/render_scripts_boot.rs index 7be6c1f1e..08e306c91 100644 --- a/crates/quarto-preview/tests/integration/render_scripts_boot.rs +++ b/crates/quarto-preview/tests/integration/render_scripts_boot.rs @@ -117,6 +117,7 @@ async fn pre_render_scripts_run_once_at_boot() { resource_html_files: Vec::new(), cache_dir: None, allow_edit: false, + share: false, }; let server = tokio::spawn(async move { diff --git a/crates/quarto-preview/tests/integration/share.rs b/crates/quarto-preview/tests/integration/share.rs new file mode 100644 index 000000000..4edf1aa1d --- /dev/null +++ b/crates/quarto-preview/tests/integration/share.rs @@ -0,0 +1,168 @@ +//! `--share` glue tests (live-share plan Phase 2, bd-jhvkwosw). +//! +//! Hermetic: `EndpointPreset::HermeticLoopback` on both sides — no n0 +//! relays, pkarr, or DNS in CI. The n0-preset production path is covered +//! by the phase's mandatory recorded end-to-end run instead. + +use std::time::Duration; + +use quarto_p2p::{EndpointPreset, TunnelClient, TunnelClientConfig, TunnelHostConfig}; +use quarto_preview::share::{format_share_banner, start_share_session}; + +/// Generous cap for individual awaits so a broken tunnel fails the test +/// instead of hanging it. +const STEP_TIMEOUT: Duration = Duration::from_secs(20); + +fn hermetic_host_cfg() -> TunnelHostConfig { + TunnelHostConfig { + preset: EndpointPreset::HermeticLoopback, + ..Default::default() + } +} + +/// The core Phase 2 unit: the share glue spawns a tunnel whose target is +/// `127.0.0.1:{port}` (the pre-resolved preview port), and the join +/// banner goes through the injected callback — no stdout scraping. +#[tokio::test] +async fn share_glue_tunnels_to_preview_port_and_announces_join_string() { + // Tiny axum server standing in for the preview hub on the loopback + // port the CLI would have pre-resolved. + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind stand-in preview server"); + let port = listener.local_addr().expect("local_addr").port(); + let app = axum::Router::new().route( + "/health", + axum::routing::get(|| async { "SHARE-GLUE-MARKER" }), + ); + tokio::spawn(async move { + axum::serve(listener, app).await.expect("axum serve"); + }); + + let mut announced: Vec = Vec::new(); + let session = tokio::time::timeout( + STEP_TIMEOUT, + start_share_session(hermetic_host_cfg(), "127.0.0.1", port, false, |banner| { + announced.push(banner.to_string()) + }), + ) + .await + .expect("start_share_session should not hang") + .expect("share session starts"); + + // Banner delivered exactly once, via the callback. + assert_eq!(announced.len(), 1, "banner must be announced exactly once"); + let banner = &announced[0]; + let join_string = session.ticket.to_string(); + assert!(join_string.starts_with("q2preview")); + + // The ready-to-paste line is bare — `q2 preview --join ` with + // nothing else on it — and nothing follows it in the banner, so a + // triple-click / drag copy survives terminal wrapping. + let join_line = banner + .lines() + .find(|l| l.contains("--join")) + .expect("banner has a join line"); + assert_eq!(join_line, format!("q2 preview --join {join_string}")); + assert!( + banner.trim_end().ends_with(&join_string), + "nothing may follow the join line; got banner:\n{banner}" + ); + + // Hermetic endpoints have no relay addr → the banner must carry the + // direct/LAN-only notice (this is how relay-unreachable surfaces to + // users; quarto-p2p's tracing::warn is filtered out at default -v 0). + assert!( + !session.ticket.has_relay_addr(), + "hermetic ticket unexpectedly carries a relay addr" + ); + assert!( + banner.contains("direct/LAN"), + "no-relay banner must warn about direct/LAN-only reachability:\n{banner}" + ); + + // The ticket's tunnel target is the stand-in server: a hermetic + // guest client can fetch through it. + let (local, client) = tokio::time::timeout( + STEP_TIMEOUT, + TunnelClient::bind( + TunnelClientConfig { + preset: EndpointPreset::HermeticLoopback, + }, + session.ticket.clone(), + "127.0.0.1:0".parse().unwrap(), + ), + ) + .await + .expect("client bind should not hang") + .expect("tunnel client binds"); + + let body = tokio::time::timeout(STEP_TIMEOUT, reqwest::get(format!("http://{local}/health"))) + .await + .expect("GET through tunnel should not hang") + .expect("GET through tunnel succeeds") + .text() + .await + .expect("response body"); + assert_eq!( + body, "SHARE-GLUE-MARKER", + "tunnel target must be 127.0.0.1:{port}" + ); + + client.shutdown().await.expect("client shutdown"); + session.shutdown().await.expect("session shutdown"); +} + +/// Banner wording: what the token grants must be printed at share time +/// (security model). Read-only sessions must not claim edit capability; +/// `--allow-edit` sessions must warn about disk writes. +#[test] +fn banner_states_capabilities_per_allow_edit() { + let read_only = format_share_banner("q2previewexample", false, true); + assert!( + read_only.contains("VIEW") && read_only.contains("RE-RUN"), + "banner must state the view + re-run capability:\n{read_only}" + ); + assert!( + !read_only.contains("EDIT"), + "read-only banner must not claim edit capability:\n{read_only}" + ); + + let editable = format_share_banner("q2previewexample", true, true); + assert!( + editable.contains("EDIT") && editable.contains("--allow-edit"), + "--allow-edit banner must warn that guests can edit files on this machine:\n{editable}" + ); +} + +/// Relay reachability: when the endpoint never came online, the banner +/// says so (guests on other networks won't be able to join). +#[test] +fn banner_warns_when_relay_unreachable() { + let no_relay = format_share_banner("q2previewexample", false, false); + assert!( + no_relay.contains("direct/LAN"), + "no-relay banner must warn about direct/LAN-only reachability:\n{no_relay}" + ); + + let with_relay = format_share_banner("q2previewexample", false, true); + assert!( + !with_relay.contains("direct/LAN"), + "relay-reachable banner must not carry the LAN-only warning:\n{with_relay}" + ); +} + +/// The join line stays last in every banner variant (copy-paste contract). +#[test] +fn banner_join_line_is_always_last() { + for (allow_edit, relay) in [(false, true), (true, true), (false, false), (true, false)] { + let banner = format_share_banner("q2previewexample", allow_edit, relay); + assert!( + banner + .trim_end() + .ends_with("q2 preview --join q2previewexample"), + "join line must be the banner's last content \ + (allow_edit={allow_edit}, relay={relay}):\n{banner}" + ); + } +} diff --git a/crates/quarto-preview/tests/integration/staleness.rs b/crates/quarto-preview/tests/integration/staleness.rs index acf9396bf..ca1ef45d8 100644 --- a/crates/quarto-preview/tests/integration/staleness.rs +++ b/crates/quarto-preview/tests/integration/staleness.rs @@ -112,6 +112,7 @@ async fn cell_edit_flips_staleness_in_sidecar() { resource_html_files: Vec::new(), cache_dir: None, allow_edit: false, + share: false, }; // ── Boot the server, capture the HubContext via on_ready ─────── diff --git a/crates/quarto/src/commands/preview.rs b/crates/quarto/src/commands/preview.rs index c0becb8aa..c61242f67 100644 --- a/crates/quarto/src/commands/preview.rs +++ b/crates/quarto/src/commands/preview.rs @@ -46,6 +46,10 @@ pub struct PreviewArgs { /// files on disk (bd-ov4gqk3m). Off by default: without it the /// preview is read-only end to end. pub allow_edit: bool, + /// Share this preview session over an end-to-end encrypted iroh + /// tunnel (bd-jhvkwosw). The server prints a `q2preview…` join + /// string; the HTTP port itself stays loopback-bound. + pub share: bool, } pub fn execute(args: PreviewArgs) -> Result<()> { @@ -220,6 +224,7 @@ async fn run(args: PreviewArgs) -> Result<()> { // (per-session; tracked as a follow-up for per-project reuse). cache_dir: None, allow_edit: args.allow_edit, + share: args.share, }; quarto_preview::run(config).await } diff --git a/crates/quarto/src/main.rs b/crates/quarto/src/main.rs index 8d92c8afb..234255709 100644 --- a/crates/quarto/src/main.rs +++ b/crates/quarto/src/main.rs @@ -240,6 +240,22 @@ enum Commands { /// document changes to your files. #[arg(long)] allow_edit: bool, + + /// Share this preview session over an end-to-end encrypted + /// peer-to-peer tunnel (via iroh). Prints a join string; + /// anyone who has it can VIEW the project and RE-RUN its code + /// on this machine (and EDIT the files if --allow-edit is also + /// set), so treat the string like a password. + #[arg(long)] + share: bool, + + /// Join a shared preview session using the `q2preview…` string + /// printed by `q2 preview --share` on the host machine. + // Hidden until the guest path lands (live-share plan Phase 3, + // bd-6y0p1bne); declared now so the --share conflict is real. + // Phase 3 unhides it and adds its full conflict matrix. + #[arg(long, value_name = "TICKET", conflicts_with = "share", hide = true)] + join: Option, }, /// Serve a Shiny interactive document @@ -723,6 +739,70 @@ enum TraceCommand { }, } +#[cfg(test)] +mod cli_parse_tests { + //! clap parse harness (live-share plan, Phase 2). These are the first + //! parse-level tests for the `q2` CLI; the Phase 3 `--join` conflict + //! matrix extends this module. + + use clap::Parser; + + use super::{Cli, Commands}; + + /// Parse argv (without the implicit binary name) into `Cli`. + fn try_parse(args: &[&str]) -> Result { + Cli::try_parse_from(std::iter::once("q2").chain(args.iter().copied())) + } + + /// Unwrap a parsed `Preview` command or panic with the actual variant. + fn parse_preview(args: &[&str]) -> Commands { + let cli = try_parse(args).expect("args should parse"); + match cli.command { + cmd @ Commands::Preview { .. } => cmd, + _ => panic!("expected a Preview command from {args:?}"), + } + } + + #[test] + fn preview_share_flag_parses() { + let Commands::Preview { share, .. } = parse_preview(&["preview", "--share"]) else { + unreachable!() + }; + assert!(share, "--share must set PreviewArgs::share"); + } + + #[test] + fn preview_share_defaults_off() { + let Commands::Preview { share, .. } = parse_preview(&["preview"]) else { + unreachable!() + }; + assert!(!share, "share must default to false"); + } + + #[test] + fn preview_share_composes_with_allow_edit() { + // Composition from the plan's CLI surface: `--share --allow-edit` + // (viewer with inline-edit write-back for guests). + let Commands::Preview { + share, allow_edit, .. + } = parse_preview(&["preview", "--share", "--allow-edit"]) + else { + unreachable!() + }; + assert!(share && allow_edit, "--share --allow-edit must both parse"); + } + + #[test] + fn preview_share_conflicts_with_join() { + // (match instead of expect_err: `Cli` deliberately has no Debug impl) + let err = match try_parse(&["preview", "--share", "--join", "x"]) { + Ok(_) => panic!("--share and --join are host vs. guest; must conflict"), + Err(e) => e, + }; + assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict); + } +} + fn main() -> Result<()> { // Install Quarto's `Q-*` error catalog into the catalog-agnostic // `quarto-error-reporting` host, so diagnostics can resolve docs URLs and @@ -784,16 +864,27 @@ fn main() -> Result<()> { preview_dir, no_project, allow_edit, - } => commands::preview::execute(commands::preview::PreviewArgs { - path, - port, - host, - no_browser, - data_dir, - preview_dir, - no_project, - allow_edit, - }), + share, + join, + } => { + if join.is_some() { + anyhow::bail!( + "`q2 preview --join` is not implemented yet \ + (guest support lands with Phase 3 of the live-share plan)" + ); + } + commands::preview::execute(commands::preview::PreviewArgs { + path, + port, + host, + no_browser, + data_dir, + preview_dir, + no_project, + allow_edit, + share, + }) + } Commands::Serve { .. } => commands::serve::execute(), Commands::Create { type_, From bb3ae70551d524e081da1f5689488a658aab9dad Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Thu, 6 Aug 2026 11:04:07 +0100 Subject: [PATCH 06/22] =?UTF-8?q?feat(preview):=20Phase=203=20=E2=80=94=20?= =?UTF-8?q?q2=20preview=20--join=20guest=20(bd-6y0p1bne)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Guest side of live share: `q2 preview --join ` parses the q2preview join string, dials the host over iroh, and serves the shared session on a local loopback proxy — no local project, TempDir, or hub. CLI (crates/quarto): - --join unhidden with the full conflict matrix (path, --share, --no-project, --allow-edit, --data-dir, --preview-dir rejected; --port/--host/--no-browser compose); --ui joins the matrix in Phase 4 with the flag itself - run_join: clear error UX for malformed tickets, unreachable hosts (bounded 10 s dial), and rejected tokens; status lines from the tunnel watch channel ("connected via direct connection|relay", "connection lost — reconnecting…"); Ctrl-C teardown (29 ms connected, 3.0 s while reconnecting — iroh's close budget) - browser-open gated on the first GET /health *through the tunnel* (wait_until_healthy; a local TCP accept would lie when the host is gone), open-anyway-on-timeout floor as in host mode quarto-p2p: - TunnelStatus::Connected now carries a PathKind (Direct/Relay/Unknown) fed by a per-connection paths_stream() watcher; conn-generation guard keeps a dying connection's straggler snapshot from overwriting the re-dialed connection's kind - terminal TunnelStatus::Rejected: the client maps the host's unauthorized close (shared ERROR_CODE_UNAUTHORIZED) to a no-re-dial terminal state instead of spinning on a token that can never succeed Tests (landed failing-first; full suite 10897 passed, xtask verify --skip-hub-build green, WASM closure still iroh-free): - cli_parse_tests: 7 new conflict/compose tests - quarto-p2p: status_reports_direct_path_kind, rejected_token_flips_status_terminal - money test quarto-preview::join_tunnel::guest_syncs_project_through_tunnel: real hub in-process + hermetic tunnel; /health via guest port matches direct, samod dial_websocket through the tunnel syncs the files map - wait_until_healthy unit tests (200 / non-200 keeps polling / dead) Single-machine e2e with two concurrent --join guests recorded in the plan (browser render + live-edit propagation, screenshots inspected). Cross-machine n0-relay leg still open — needs a second machine or a GH-Actions guest (push approval), tracked on the strand. No snapshot (.snap) changes. Plan: claude-notes/plans/2026-08-03-q2-preview-live-share-iroh.md --- Cargo.lock | 2 + .../2026-08-03-q2-preview-live-share-iroh.md | 127 +++++++- crates/quarto-p2p/Cargo.toml | 3 + crates/quarto-p2p/src/client.rs | 88 +++++- crates/quarto-p2p/src/host.rs | 6 +- crates/quarto-p2p/src/lib.rs | 37 ++- crates/quarto-p2p/tests/integration/tunnel.rs | 114 +++++++- crates/quarto-preview/Cargo.toml | 3 + .../tests/integration/join_tunnel.rs | 174 +++++++++++ .../quarto-preview/tests/integration/main.rs | 1 + crates/quarto/Cargo.toml | 10 +- crates/quarto/src/commands/preview.rs | 272 ++++++++++++++++++ crates/quarto/src/main.rs | 142 +++++++-- 13 files changed, 931 insertions(+), 48 deletions(-) create mode 100644 crates/quarto-preview/tests/integration/join_tunnel.rs diff --git a/Cargo.lock b/Cargo.lock index 08d8e4184..2192f57b9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4815,6 +4815,7 @@ dependencies = [ "quarto-hub-provider", "quarto-lsp", "quarto-mcp-launcher", + "quarto-p2p", "quarto-preview", "quarto-project-create", "quarto-publish", @@ -5278,6 +5279,7 @@ dependencies = [ "tokio", "tower 0.5.3", "tracing", + "url", ] [[package]] diff --git a/claude-notes/plans/2026-08-03-q2-preview-live-share-iroh.md b/claude-notes/plans/2026-08-03-q2-preview-live-share-iroh.md index f2bb8a5df..7f63b7727 100644 --- a/claude-notes/plans/2026-08-03-q2-preview-live-share-iroh.md +++ b/claude-notes/plans/2026-08-03-q2-preview-live-share-iroh.md @@ -887,12 +887,24 @@ lands in Phase 3 — the tunnel path exercised (ticket parse → ## Phase 3 — `q2 preview --join ` (guest) +*(implemented 2026-08-06, bd-6y0p1bne; tests landed first and were +observed failing — 5/6 new CLI conflict tests FAIL (only the Phase 2 +`--share` conflict pre-existed), the two new quarto-p2p status tests +fail as E0432/E0599 compile errors (the expected failure mode for the +`TunnelStatus` shape change, matching Phase 2's E0026 precedent) — +then went green with the implementation)* + **Tests first:** -- [ ] CLI conflict matrix: `--join` × each of {path, `--share`, +- [x] CLI conflict matrix: `--join` × each of {path, `--share`, `--no-project`, `--allow-edit`, `--ui editor`, `--data-dir`, `--preview-dir`} rejected; × {`--port`, `--no-browser`, `--host`} accepted -- [ ] **The money test** (integration, `crates/quarto-preview` or `quarto-p2p` + *(done except `--ui editor`: the `--ui` flag itself is Phase 4 + (bd-jt1etjbn, not yet implemented), so its `--join` conflict lands + there with the flag — noted in the Phase 4 items below. Conflicts + asserted as `ErrorKind::ArgumentConflict`; the accepted set is + pinned by `preview_join_composes_with_guest_flags`)* +- [x] **The money test** (integration, `crates/quarto-preview` or `quarto-p2p` with a dev-dep on `quarto-hub`): start a real preview hub in-process on a fixture project (`run_server_with`), `TunnelHost` in front of it, `TunnelClient` on a random port (all hermetic-iroh); then through the @@ -911,16 +923,34 @@ lands in Phase 3 — the tunnel path exercised (ticket parse → dials via `repo.dial(BackoffConfig, BearerDialer)` because `dial_websocket` cannot set auth headers (that distinction matters again in Phase 5) + *(landed as `crates/quarto-preview/tests/integration/join_tunnel.rs::guest_syncs_project_through_tunnel`, + via `quarto_preview::run` (which wraps `run_server_with`) — no new + dev-dep needed beyond `url` (samod + quarto-hub are already regular + deps; samod's `tungstenite` feature unifies in via quarto-hub). + Composition-of-tested-parts, so it passed on first run — that is + the expected outcome for this proof, unlike the fail-first items + above. Also asserts tunnel `/health` == direct `/health`)* **Implementation:** -- [ ] `--join ` arg; guest path in `commands/preview.rs::run` that +- [x] `--join ` arg; guest path in `commands/preview.rs::run` that bypasses project resolution/TempDir/hub entirely: parse ticket, `TunnelClient::bind(("127.0.0.1"|--host, --port or probed))`, print URL -- [ ] Browser-open readiness = first successful `GET /health` **through the + *(clap arg unhidden + `conflicts_with_all`; `run_join` in + `commands/preview.rs`; `--host` resolves via `tokio::net::lookup_host` + so `localhost` works; explicit `--port` gets the friendly + `validate_explicit_port` check, otherwise the OS assigns and + `TunnelClient::bind` reports the bound port back — no pre-probe + needed since nothing must print before a second server starts)* +- [x] Browser-open readiness = first successful `GET /health` **through the tunnel** (extend `wait_until_accepting`, `preview.rs:294-327` — a local TCP accept alone would lie when the tunnel is dead) -- [ ] Status messaging from the client watch channel: "connected via + *(new `wait_until_healthy` + `health_get_ok` (hand-rolled HTTP/1.1 + GET, no new HTTP-client dep): same backoff shape and + open-anyway-on-timeout floor as `wait_until_accepting`, 15 s budget, + 5 s per-attempt cap; unit tests cover 200 / non-200-keeps-polling / + nothing-listening)* +- [x] Status messaging from the client watch channel: "connected via ", "reconnecting…". API pinned: `Connection::paths()` returns a `PathList` whose `Path` entries expose `is_selected()` / `is_relay()` / `is_ip()` / `rtt()` @@ -930,14 +960,94 @@ lands in Phase 3 — the tunnel path exercised (ticket parse → path's `is_relay()` is the direct-vs-relay discriminator. (`Endpoint::remote_info(EndpointId)` also exists, `endpoint.rs:1623`, but the per-connection API is the right one here) -- [ ] Ctrl-C teardown; clear error UX for: malformed ticket, host unreachable, + *(API change in quarto-p2p: `TunnelStatus::Connected` now carries a + `PathKind` ({Direct, Relay, Unknown}, Display-able), fed by a + per-connection `paths_stream()` watcher task (used over + `path_events()` because it yields the current snapshot on first + poll — no missed initial selection). A `conn_generation` counter + guards against a dying connection's straggler snapshot overwriting + the re-dialed connection's kind. `futures` promoted from dev-dep to + dep for `StreamExt`. Pinned by `tunnel::status_reports_direct_path_kind`)* +- [x] Ctrl-C teardown; clear error UX for: malformed ticket, host unreachable, token rejected (host rotated/session ended) + *(malformed → parse error naming `q2 preview --share` + the + wrapped-line copy warning, exit 1; unreachable → bounded 10 s dial + timeout then "could not reach the share host", exit 1; token + rejected → second quarto-p2p API change: the client supervisor + inspects `conn.closed()` and maps an `ApplicationClosed` carrying + the host's `ERROR_CODE_UNAUTHORIZED` (1, now a crate-shared const) + to a **terminal** `TunnelStatus::Rejected` — no re-dial spin, since + the same token can never succeed — which the CLI turns into "the + host rejected this join string… ask for a fresh `--share` string" + and a non-zero exit. Pinned hermetically by + `tunnel::rejected_token_flips_status_terminal` (client side; the + target-sees-zero-TCP-conns half was already pinned by Phase 1's + `wrong_token_rejected`). Ctrl-C: select on `ctrl_c()` vs the + status reporter, then `TunnelClientHandle::shutdown()`; measured + 29 ms exit when Connected, 3.0 s when Reconnecting (iroh's + documented graceful-close budget with a dial in flight — + accepted))* - [ ] **End-to-end (mandatory, record here):** cross-machine host/guest run with the real n0 relay path (netns is Linux-only — same logistics as Gate 0 Q3: two physical machines); inspect rendered output in the guest browser; note "verified in browser". Join **two guests concurrently** at least once (v1 scope is N guests — architecture scope note) + *(single-machine leg executed 2026-08-06 with two concurrent guests — + see "Phase 3 end-to-end record" below; the **cross-machine n0-relay + leg is still open** — it needs a second physical machine (user-driven, + runbook analogous to Gate 0's) or a GH-Actions guest like Gate 0 Q3 + (needs push approval). The "connected via relay" message rendering + is exercised only by that leg (local runs select the direct path); + the wrapped-ticket triple-click copy check also still wants a human + terminal)* + +### Phase 3 end-to-end record (single-machine legs, 2026-08-06) + +All output inspected. Binary: `target/debug/q2` at the Phase 3 tree +(guests use the real `q2 preview --join` — no example shims). Fixture: +`_quarto.yml` + `index.qmd` (`MARKER-0`) + `about.qmd` in a scratchpad +dir. + +- **Host:** `q2 preview --share --no-browser --port 9377` + printed the boot URL, then the banner ending in a bare + `q2 preview --join q2previewadwfpfxpcncsiqj2qf6…` line (203-char + ticket; relay reachable, so no direct/LAN warning). +- **Two concurrent guests:** `q2 preview --join --no-browser + --port 9280|9281` → each printed + `→ http://127.0.0.1:928x/` and `● connected via direct connection`. +- **`/health` through both guest ports** returned **byte-identical** + payloads to direct (`diff` clean), including + `"index_document_id":"4VQ27RYYrpZ5W3D5Fph6NE5tmYoy"` and + `"qmd_file_count":2`. +- **Browser (Playwright Chromium** at `…/?page=index.qmd`, frames + scanned per the Gate 0 iframe finding): document rendered through the + tunnel on **both** guests in **1.46 s**; screenshots inspected + (`MARKER-0` visible). Note: guests ran `--no-browser` and Playwright + drove the pages, so the auto-open itself wasn't exercised — its + gating helper (`wait_until_healthy`) is unit-tested and the opener is + the same `open_browser_or_log` host mode uses. +- **Live edit:** host-side `MARKER-0`→`MARKER-1` propagated to the + already-open guest page in **~0.6 s** (marker visible 2.2 s after the + edit including a second page's fresh boot + both screenshots); a + fresh guest boot after the edit rendered `MARKER-1` in 1.45 s. + Post-edit screenshots inspected on both guests. +- **Error UX, all observed:** `--join not-a-ticket` → + `invalid join string (wrong prefix, expected q2preview)` + guidance, + `EXIT=1`; SIGINT of the host flipped the surviving guest to + `○ connection lost — reconnecting…`; joining the dead host's ticket + failed after the bounded 10.0 s dial timeout with + `could not reach the share host …`, `EXIT=1`. Token-rejected is + covered hermetically (`rejected_token_flips_status_terminal`) — not + reproducible from the CLI without hand-crafting a wrong-token ticket. +- **Ctrl-C teardown:** guest exit measured **29 ms** in the Connected + state (port unbound, `Received Ctrl-C, leaving the shared session…`); + **3.0 s** in the Reconnecting state (iroh close with a dial in + flight). Host Ctrl-C behavior unchanged from Phase 2's record. +- **Verification at this tree:** `cargo build --workspace` green; + `cargo nextest run --workspace` **10897 passed**; `cargo xtask verify + --skip-hub-build` → "All verification steps passed!"; `cargo tree -i + iroh` from `wasm-quarto-hub-client` still fails (closure clean). ## Phase 4 — `q2 preview --ui editor` (independent track) @@ -952,7 +1062,10 @@ required: `App.tsx:418-423`). - [ ] CLI: `--ui viewer` / `--ui editor` parse (clap `ValueEnum`, default `viewer`); an unknown value (`--ui monaco`) is rejected with the list - of valid values + of valid values; **`--ui` × `--join` rejected** (the one Phase 3 + conflict-matrix entry deferred here because the flag didn't exist + yet — extend `--join`'s `conflicts_with_all` in `main.rs` and add + the parse test alongside Phase 3's `assert_join_conflict` helper) - [ ] Rust unit: `--ui editor` boot URL builder emits `http://{host}:{port}/#/share/{indexDocId}?server=%2Fws&file={rel}&name={project}` (doc id **without** the `automerge:` prefix — `routing.ts:420`; `file` diff --git a/crates/quarto-p2p/Cargo.toml b/crates/quarto-p2p/Cargo.toml index c3f7db6ee..b3225e8dd 100644 --- a/crates/quarto-p2p/Cargo.toml +++ b/crates/quarto-p2p/Cargo.toml @@ -13,6 +13,9 @@ license.workspace = true description = "iroh-based P2P tunnel for q2 preview live share (--share / --join)" [dependencies] +# StreamExt for the client's per-connection path watcher +# (`Connection::paths_stream`), which feeds the direct-vs-relay status. +futures = "0.3" iroh = "1.0.3" iroh-tickets = "1.0" # Ticket wire format (postcard of {addr, token}); same encoding stack diff --git a/crates/quarto-p2p/src/client.rs b/crates/quarto-p2p/src/client.rs index 8924760a5..b3d5d580a 100644 --- a/crates/quarto-p2p/src/client.rs +++ b/crates/quarto-p2p/src/client.rs @@ -4,17 +4,22 @@ use std::net::SocketAddr; use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; use std::time::Duration; +use futures::StreamExt; use iroh::address_lookup::memory::MemoryLookup; -use iroh::endpoint::Connection; +use iroh::endpoint::{Connection, ConnectionError, PathList, VarInt}; use iroh::{Endpoint, EndpointAddr}; use tokio::net::{TcpListener, TcpStream}; use tokio::sync::{RwLock, watch}; use tokio::task::JoinHandle; use tokio::time::timeout; -use crate::{ALPN, EndpointPreset, PreviewShareTicket, TOKEN_LEN, TunnelError, TunnelStatus}; +use crate::{ + ALPN, ERROR_CODE_UNAUTHORIZED, EndpointPreset, PathKind, PreviewShareTicket, TOKEN_LEN, + TunnelError, TunnelStatus, +}; /// Per-attempt cap on dialing the host (QUIC handshakes against a dead /// UDP addr otherwise pend on retransmits for a long time). @@ -71,15 +76,18 @@ impl TunnelClient { let listener = TcpListener::bind(local).await.map_err(TunnelError::Proxy)?; let local_addr = listener.local_addr().map_err(TunnelError::Proxy)?; - let (status_tx, status_rx) = watch::channel(TunnelStatus::Connected); + let (status_tx, status_rx) = + watch::channel(TunnelStatus::Connected(selected_path_kind(&conn.paths()))); let shared = Arc::new(Shared { endpoint: endpoint.clone(), remote: ticket.addr, token: ticket.token, - conn: RwLock::new(conn), + conn: RwLock::new(conn.clone()), + conn_generation: AtomicU64::new(0), status_tx, status_rx: status_rx.clone(), }); + tokio::spawn(watch_paths(conn, shared.clone(), 0)); let supervisor = tokio::spawn(supervise_connection(shared.clone())); let acceptor = tokio::spawn(accept_loop(listener, shared)); @@ -106,7 +114,8 @@ pub struct TunnelClientHandle { } impl TunnelClientHandle { - /// Watch channel for CLI messaging ("connected", "reconnecting…"). + /// Watch channel for CLI messaging ("connected via …", + /// "reconnecting…", "rejected"). pub fn status(&self) -> watch::Receiver { self.status_rx.clone() } @@ -132,16 +141,62 @@ struct Shared { remote: EndpointAddr, token: [u8; TOKEN_LEN], conn: RwLock, + /// Bumped on every re-dial; a path watcher only updates the status + /// when its connection is still the current generation, so a + /// straggling snapshot from a dying connection can't overwrite the + /// fresh connection's path kind. + conn_generation: AtomicU64, status_tx: watch::Sender, status_rx: watch::Receiver, } +/// Classify the selected path of a connection snapshot. +fn selected_path_kind(paths: &PathList<'_>) -> PathKind { + match paths.iter().find(|p| p.is_selected()) { + Some(p) if p.is_relay() => PathKind::Relay, + Some(p) if p.is_ip() => PathKind::Direct, + // Custom transports don't occur here; selection may also simply + // not have happened yet. + _ => PathKind::Unknown, + } +} + +/// Follow one connection's path snapshots, updating a live `Connected` +/// status when the selected path changes kind (e.g. relay → direct once +/// hole-punching lands). Ends when the connection closes. Never touches +/// `Reconnecting`/`Rejected` — those belong to the supervisor. +async fn watch_paths(conn: Connection, shared: Arc, generation: u64) { + let mut stream = std::pin::pin!(conn.paths_stream()); + while let Some(paths) = stream.as_mut().next().await { + let kind = selected_path_kind(&paths); + if shared.conn_generation.load(Ordering::SeqCst) != generation { + return; // a newer connection owns the status now + } + shared.status_tx.send_if_modified(|status| match status { + TunnelStatus::Connected(old) if *old != kind => { + *status = TunnelStatus::Connected(kind); + true + } + _ => false, + }); + } +} + /// Watches the current connection for death and re-dials with exponential -/// backoff, updating the status channel around the outage. +/// backoff, updating the status channel around the outage. A close carrying +/// the host's "unauthorized" code is terminal: the session token was +/// rejected, so re-dialing with the same ticket can never succeed. async fn supervise_connection(shared: Arc) { loop { let conn = shared.conn.read().await.clone(); let reason = conn.closed().await; + if let ConnectionError::ApplicationClosed(close) = &reason + && close.error_code == VarInt::from_u32(ERROR_CODE_UNAUTHORIZED) + { + tracing::warn!("preview tunnel: the host rejected the session token"); + shared.status_tx.send_replace(TunnelStatus::Rejected); + return; + } tracing::info!(?reason, "preview tunnel: connection lost; re-dialing"); shared.status_tx.send_replace(TunnelStatus::Reconnecting); @@ -154,8 +209,14 @@ async fn supervise_connection(shared: Arc) { .await { Ok(Ok(new_conn)) => { - *shared.conn.write().await = new_conn; - shared.status_tx.send_replace(TunnelStatus::Connected); + let generation = shared.conn_generation.fetch_add(1, Ordering::SeqCst) + 1; + *shared.conn.write().await = new_conn.clone(); + shared + .status_tx + .send_replace(TunnelStatus::Connected(selected_path_kind( + &new_conn.paths(), + ))); + tokio::spawn(watch_paths(new_conn, shared.clone(), generation)); tracing::info!("preview tunnel: reconnected"); break; } @@ -201,14 +262,17 @@ async fn handle_local_conn(mut tcp: TcpStream, shared: Arc) { return; // budget exhausted; drop the TCP conn }; let mut status = shared.status_rx.clone(); - if timeout( + match timeout( remaining, - status.wait_for(|s| *s == TunnelStatus::Connected), + status.wait_for(|s| !matches!(s, TunnelStatus::Reconnecting)), ) .await - .is_err() { - return; + // Reconnected: retry open_bi on the swapped connection. + Ok(Ok(current)) if matches!(*current, TunnelStatus::Connected(_)) => {} + // Rejected (terminal), channel closed, or budget + // exhausted: drop the TCP conn. + _ => return, } } } diff --git a/crates/quarto-p2p/src/host.rs b/crates/quarto-p2p/src/host.rs index fbcbdf2d3..185402406 100644 --- a/crates/quarto-p2p/src/host.rs +++ b/crates/quarto-p2p/src/host.rs @@ -11,7 +11,9 @@ use iroh::{Endpoint, EndpointAddr, SecretKey, Watcher}; use subtle::ConstantTimeEq; use tokio::net::TcpStream; -use crate::{ALPN, EndpointPreset, PreviewShareTicket, TOKEN_LEN, TunnelError}; +use crate::{ + ALPN, ERROR_CODE_UNAUTHORIZED, EndpointPreset, PreviewShareTicket, TOKEN_LEN, TunnelError, +}; /// How long a freshly accepted stream may take to present its token. const TOKEN_READ_TIMEOUT: Duration = Duration::from_secs(10); @@ -26,8 +28,6 @@ const ONLINE_TIMEOUT: Duration = Duration::from_secs(10); /// dialable address. const ADDR_TIMEOUT: Duration = Duration::from_secs(10); -/// QUIC application error code for a stream that failed token auth. -const ERROR_CODE_UNAUTHORIZED: u32 = 1; /// QUIC application error code for "the local target refused a connection". const ERROR_CODE_TARGET_UNAVAILABLE: u32 = 2; diff --git a/crates/quarto-p2p/src/lib.rs b/crates/quarto-p2p/src/lib.rs index 8aac6f5a4..4c6d8fe36 100644 --- a/crates/quarto-p2p/src/lib.rs +++ b/crates/quarto-p2p/src/lib.rs @@ -44,14 +44,49 @@ pub enum EndpointPreset { HermeticLoopback, } +/// Which kind of network path currently carries the tunnel's traffic +/// (the selected path's `is_relay()` is the discriminator). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PathKind { + /// Direct IP path (LAN or hole-punched). + Direct, + /// Via a relay server (the designed fallback when hole-punching + /// fails; traffic stays end-to-end encrypted). + Relay, + /// No selected path is visible right now (e.g. mid-migration). + Unknown, +} + +impl std::fmt::Display for PathKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + PathKind::Direct => "direct connection", + PathKind::Relay => "relay", + PathKind::Unknown => "unknown path", + }) + } +} + /// Client connection status, surfaced to the CLI ("connected via relay", /// "reconnecting…"). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum TunnelStatus { - Connected, + /// Tunnel is up; the payload says what kind of path carries it (and + /// tracks upgrades, e.g. relay → direct once hole-punching lands). + Connected(PathKind), + /// Connection lost; the client is re-dialing with backoff. Reconnecting, + /// The host closed the connection as unauthorized: this join + /// string's token was rejected (the share session ended or the host + /// restarted with a fresh token). Terminal — re-dialing with the + /// same token cannot succeed, so the client stops trying. + Rejected, } +/// QUIC application error code the host closes with when a stream fails +/// token auth; the client maps it to [`TunnelStatus::Rejected`]. +pub(crate) const ERROR_CODE_UNAUTHORIZED: u32 = 1; + pub(crate) type BoxedError = Box; /// Bind an iroh endpoint for the given preset (shared by host and client). diff --git a/crates/quarto-p2p/tests/integration/tunnel.rs b/crates/quarto-p2p/tests/integration/tunnel.rs index 11e0732f6..109eac26d 100644 --- a/crates/quarto-p2p/tests/integration/tunnel.rs +++ b/crates/quarto-p2p/tests/integration/tunnel.rs @@ -11,7 +11,10 @@ use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; use axum::routing::get; use futures::{SinkExt, StreamExt}; use iroh::{SecretKey, TransportAddr}; -use quarto_p2p::{ALPN, EndpointPreset, TunnelClient, TunnelHost, TunnelHostConfig, TunnelStatus}; +use quarto_p2p::{ + ALPN, EndpointPreset, PathKind, PreviewShareTicket, TunnelClient, TunnelHost, TunnelHostConfig, + TunnelStatus, +}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::{TcpListener, TcpStream}; use tokio::time::timeout; @@ -246,7 +249,10 @@ async fn client_redials_after_connection_loss() { .await .expect("bind client"); let mut status = client.status(); - assert_eq!(*status.borrow(), TunnelStatus::Connected); + assert!( + matches!(*status.borrow(), TunnelStatus::Connected(_)), + "fresh client must start Connected" + ); let first = http_get_close(local, "/").await; assert!( @@ -293,7 +299,7 @@ async fn client_redials_after_connection_loss() { timeout( STEP_TIMEOUT, - status.wait_for(|s| *s == TunnelStatus::Connected), + status.wait_for(|s| matches!(s, TunnelStatus::Connected(_))), ) .await .expect("status never returned to Connected") @@ -303,6 +309,108 @@ async fn client_redials_after_connection_loss() { second_host.shutdown().await.expect("second host shutdown"); } +/// Phase 3 (bd-6y0p1bne): the status watch must say *how* the tunnel is +/// connected — the CLI prints "connected via direct connection" vs +/// "connected via relay". Hermetic endpoints have IP transports only, so +/// the selected path must read as `Direct` (never `Relay`, never stuck on +/// `Unknown`). +#[tokio::test(flavor = "multi_thread")] +async fn status_reports_direct_path_kind() { + let target = spawn_http_target(Router::new().route("/", get(|| async { "path-ok" }))).await; + let (local, host, client) = spawn_tunnel_pair(target).await; + + let mut status = client.status(); + timeout( + STEP_TIMEOUT, + status.wait_for(|s| *s == TunnelStatus::Connected(PathKind::Direct)), + ) + .await + .expect("status never reported a direct path") + .expect("status channel closed"); + + // Traffic actually flows on the reported path. + let response = http_get_close(local, "/").await; + assert!(response.contains("path-ok"), "roundtrip failed: {response}"); + + client.shutdown().await.expect("client shutdown"); + host.shutdown().await.expect("host shutdown"); +} + +/// Phase 3 (bd-6y0p1bne): a stale join string — right endpoint, wrong +/// token (host restarted / session re-shared) — must surface as a +/// *terminal* `Rejected` status, not an endless silent re-dial loop. The +/// CLI turns this into "the host rejected this join string". +#[tokio::test(flavor = "multi_thread")] +async fn rejected_token_flips_status_terminal() { + // Raw TCP target that counts accepts; the rejected guest must never + // produce one. + let accepts = Arc::new(AtomicUsize::new(0)); + let listener = TcpListener::bind(any_loopback()) + .await + .expect("bind target"); + let target = listener.local_addr().expect("target addr"); + { + let accepts = accepts.clone(); + tokio::spawn(async move { + loop { + let _ = listener.accept().await; + accepts.fetch_add(1, Ordering::SeqCst); + } + }); + } + + let cfg = TunnelHostConfig { + preset: EndpointPreset::HermeticLoopback, + token: Some([0xAA; 32]), + ..Default::default() + }; + let (ticket, host) = TunnelHost::spawn(cfg, target).await.expect("spawn host"); + + // The stale string: same endpoint address, zeroed token. + let stale = PreviewShareTicket { + addr: ticket.addr.clone(), + token: [0u8; 32], + }; + let (local, client) = TunnelClient::bind(hermetic_client_cfg(), stale, any_loopback()) + .await + .expect("bind client (QUIC connect itself carries no token)"); + + // Trigger one proxied connection: the host resets the stream and + // closes the connection as unauthorized. The request's outcome is + // irrelevant; the attempt is what trips the rejection. + let _ = timeout(STEP_TIMEOUT, try_http_get_close(local, "/")) + .await + .expect("rejected request should fail fast, not hang"); + + let mut status = client.status(); + timeout( + STEP_TIMEOUT, + status.wait_for(|s| *s == TunnelStatus::Rejected), + ) + .await + .expect("client never reported the token rejection") + .expect("status channel closed"); + + // Terminal: the supervisor must not re-dial its way back to + // Connected with a token the host refuses. + tokio::time::sleep(Duration::from_millis(750)).await; + assert_eq!( + *client.status().borrow(), + TunnelStatus::Rejected, + "Rejected must be terminal" + ); + + // The unauthenticated stream never reached the target. + assert_eq!( + accepts.load(Ordering::SeqCst), + 0, + "rejected streams must not reach the target" + ); + + client.shutdown().await.expect("client shutdown"); + host.shutdown().await.expect("host shutdown"); +} + #[tokio::test(flavor = "multi_thread")] async fn half_close_propagates() { // Raw TCP target driven by a task whose asserts propagate via join. diff --git a/crates/quarto-preview/Cargo.toml b/crates/quarto-preview/Cargo.toml index 3d57a39a0..0e3b1dcf5 100644 --- a/crates/quarto-preview/Cargo.toml +++ b/crates/quarto-preview/Cargo.toml @@ -42,6 +42,9 @@ tower = "0.5" http-body-util = "0.1" http = "1" reqwest = { version = "0.12", features = ["blocking", "json"] } +# The Phase 3 money test dials samod's `/ws` through the tunnel +# (`dial_websocket` takes a `url::Url`). +url = "2" [lints] workspace = true diff --git a/crates/quarto-preview/tests/integration/join_tunnel.rs b/crates/quarto-preview/tests/integration/join_tunnel.rs new file mode 100644 index 000000000..f91a42a51 --- /dev/null +++ b/crates/quarto-preview/tests/integration/join_tunnel.rs @@ -0,0 +1,174 @@ +//! Phase 3 money test (live-share plan, bd-6y0p1bne): a real preview hub +//! in-process on a fixture project, a hermetic tunnel in front of it, and +//! a samod client dialing `/ws` through the guest port — proves +//! automerge-sync-over-tunnel without a browser. +//! +//! Hermetic: `EndpointPreset::HermeticLoopback` on both tunnel ends — no +//! n0 relays, pkarr, or DNS in CI. `repo.dial_websocket` works here +//! because preview's `/ws` takes no credentials (`auth_config: None` +//! skips credential and Origin checks; see the plan's Phase 3 notes). + +use std::net::TcpListener as StdTcpListener; +use std::time::{Duration, Instant}; + +use quarto_hub::index::IndexDocument; +use quarto_p2p::{EndpointPreset, TunnelClient, TunnelClientConfig, TunnelHost, TunnelHostConfig}; +use quarto_preview::PreviewConfig; +use samod::{BackoffConfig, Repo}; + +/// Bind `127.0.0.1:0`, capture the assigned port, release the listener. +/// Same tiny-race trade-off as the CLI's own port probe. +fn pick_free_port() -> u16 { + let listener = StdTcpListener::bind("127.0.0.1:0").expect("probe bind"); + let port = listener.local_addr().expect("local_addr").port(); + drop(listener); + port +} + +/// Poll `GET /health` (directly, not through the tunnel) until the hub +/// is up — HubContext::new can take a beat (samod init, initial fs sync). +async fn wait_for_health(port: u16) { + let url = format!("http://127.0.0.1:{port}/health"); + let deadline = Instant::now() + Duration::from_secs(20); + let client = reqwest::Client::new(); + loop { + if let Ok(resp) = client.get(&url).send().await + && resp.status().is_success() + { + return; + } + assert!( + Instant::now() < deadline, + "preview server didn't come up on port {port} within 20s" + ); + tokio::time::sleep(Duration::from_millis(50)).await; + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn guest_syncs_project_through_tunnel() { + // Fixture project: `_quarto.yml` + two pages, like the e2e fixtures. + let project = tempfile::TempDir::with_prefix("q2-join-tunnel-proj-").unwrap(); + std::fs::write( + project.path().join("_quarto.yml"), + "project:\n type: website\n", + ) + .unwrap(); + std::fs::write(project.path().join("index.qmd"), "# Index\n\nHello.\n").unwrap(); + std::fs::write(project.path().join("about.qmd"), "# About\n").unwrap(); + let data = tempfile::TempDir::with_prefix("q2-join-tunnel-data-").unwrap(); + + let port = pick_free_port(); + let config = PreviewConfig { + host: "127.0.0.1".to_string(), + port, + project_root: Some(project.path().to_path_buf()), + single_file: None, + data_dir: data.path().to_path_buf(), + spa_dir_override: None, + engine_registry: None, + engine_policy: Default::default(), + resource_html_files: Vec::new(), + cache_dir: None, + allow_edit: false, + share: false, + }; + + // The real hub, exactly as `q2 preview` runs it. `run()` blocks until + // shutdown; the task dies with the test process (nextest isolates). + let _server = tokio::spawn(async move { + let _ = quarto_preview::run(config).await; + }); + wait_for_health(port).await; + + // Hermetic tunnel pair in front of the hub — the same shape + // `--share`/`--join` wire up with the production n0 preset. + let (ticket, tunnel_host) = TunnelHost::spawn( + TunnelHostConfig { + preset: EndpointPreset::HermeticLoopback, + ..Default::default() + }, + ([127, 0, 0, 1], port).into(), + ) + .await + .expect("spawn tunnel host"); + let (guest, tunnel_client) = TunnelClient::bind( + TunnelClientConfig { + preset: EndpointPreset::HermeticLoopback, + }, + ticket, + "127.0.0.1:0".parse().unwrap(), + ) + .await + .expect("bind tunnel client"); + + // (a) `/health` through the guest port answers with the *host's* + // index document id — byte-identical to the direct answer. + let through_tunnel: serde_json::Value = reqwest::get(format!("http://{guest}/health")) + .await + .expect("GET /health through tunnel") + .error_for_status() + .expect("200 through tunnel") + .json() + .await + .expect("health json"); + let doc_id = through_tunnel["index_document_id"] + .as_str() + .expect("health carries index_document_id") + .to_string(); + assert!(!doc_id.is_empty(), "index_document_id must be non-empty"); + + let direct: serde_json::Value = reqwest::get(format!("http://127.0.0.1:{port}/health")) + .await + .expect("GET /health direct") + .json() + .await + .expect("direct health json"); + assert_eq!( + doc_id, + direct["index_document_id"].as_str().unwrap(), + "tunnel and direct /health must name the same index document" + ); + + // (b) samod client dials `/ws` through the guest port and loads the + // index document: automerge sync itself flows over the tunnel. + let repo = Repo::build_tokio().load().await; + let ws_url: url::Url = format!("ws://{guest}/ws").parse().unwrap(); + let handle = repo + .dial_websocket(ws_url, BackoffConfig::default()) + .expect("dial /ws through tunnel"); + tokio::time::timeout(Duration::from_secs(10), handle.established()) + .await + .expect("ws connection through tunnel timed out") + .expect("ws connection through tunnel failed"); + + let index = IndexDocument::load(&repo, &doc_id) + .await + .expect("load index document") + .expect("index document found through tunnel"); + + // The files map converges to the fixture's two pages. + let deadline = Instant::now() + Duration::from_secs(15); + let files = loop { + let files = index.get_all_files(); + if files.contains_key("index.qmd") && files.contains_key("about.qmd") { + break files; + } + assert!( + Instant::now() < deadline, + "fixture files never synced through the tunnel; got: {:?}", + files.keys().collect::>() + ); + tokio::time::sleep(Duration::from_millis(250)).await; + }; + // Every file entry maps to a per-file automerge doc id. + for (path, file_doc_id) in &files { + assert!( + !file_doc_id.is_empty(), + "file {path} has an empty document id" + ); + } + + tunnel_client.shutdown().await.expect("client shutdown"); + tunnel_host.shutdown().await.expect("host shutdown"); +} diff --git a/crates/quarto-preview/tests/integration/main.rs b/crates/quarto-preview/tests/integration/main.rs index 002b4ee1b..42b40b4fc 100644 --- a/crates/quarto-preview/tests/integration/main.rs +++ b/crates/quarto-preview/tests/integration/main.rs @@ -7,6 +7,7 @@ pub mod config_endpoint; pub mod diagnostics_capture_failure; pub mod diagnostics_endpoint; pub mod eager_capture; +pub mod join_tunnel; pub mod render_scripts_boot; pub mod share; pub mod smoke; diff --git a/crates/quarto/Cargo.toml b/crates/quarto/Cargo.toml index 14d0ff6eb..25d746e1d 100644 --- a/crates/quarto/Cargo.toml +++ b/crates/quarto/Cargo.toml @@ -20,8 +20,10 @@ tracing-subscriber.workspace = true serde.workspace = true serde_json.workspace = true pollster.workspace = true -# `signal` for the `provide-hub` serve loop's Ctrl-C shutdown. -tokio = { workspace = true, features = ["signal"] } +# `signal` for the `provide-hub` serve loop's Ctrl-C shutdown; `net` + +# `io-util` for `q2 preview`'s readiness probes (TCP accept probe, and +# the `--join` health probe through the tunnel). +tokio = { workspace = true, features = ["signal", "net", "io-util"] } quarto-core = { workspace = true, features = ["clap"] } quarto-error-catalog.workspace = true @@ -44,6 +46,10 @@ quarto-hub.workspace = true quarto-hub-provider = { path = "../quarto-hub-provider" } quarto-mcp-launcher = { path = "../quarto-mcp-launcher" } quarto-preview = { path = "../quarto-preview" } +# `q2 preview --join`: ticket parsing + the guest-side tunnel client +# (live-share plan Phase 3, bd-6y0p1bne). All iroh usage stays behind +# quarto-p2p's API. +quarto-p2p.workspace = true url = "2.5" quarto-publish.workspace = true quarto-sass.workspace = true diff --git a/crates/quarto/src/commands/preview.rs b/crates/quarto/src/commands/preview.rs index c61242f67..ee9bf386f 100644 --- a/crates/quarto/src/commands/preview.rs +++ b/crates/quarto/src/commands/preview.rs @@ -59,6 +59,25 @@ pub fn execute(args: PreviewArgs) -> Result<()> { runtime.block_on(run(args)) } +/// Guest-mode argument shape for `q2 preview --join ` (live-share +/// plan Phase 3, bd-6y0p1bne). Deliberately tiny: the guest has no local +/// project, hub, or disk surface — clap rejects every host-mode flag. +pub struct JoinArgs { + /// The `q2preview…` join string printed by `q2 preview --share`. + pub ticket: String, + /// Local proxy port. Default: OS-assigned. + pub port: Option, + /// Local proxy bind host. Default: 127.0.0.1. + pub host: Option, + /// Skip the browser-open step. + pub no_browser: bool, +} + +pub fn execute_join(args: JoinArgs) -> Result<()> { + let runtime = tokio::runtime::Runtime::new()?; + runtime.block_on(run_join(args)) +} + async fn run(args: PreviewArgs) -> Result<()> { // Project mode is the default (epic plan Q5); --no-project is // the explicit standalone-server escape hatch. Canonicalize so @@ -229,6 +248,191 @@ async fn run(args: PreviewArgs) -> Result<()> { quarto_preview::run(config).await } +/// Guest path (live-share plan Phase 3, bd-6y0p1bne): parse the join +/// string, dial the host over iroh, serve the session on a local +/// loopback proxy, and report connection status until Ctrl-C. Bypasses +/// project resolution, the TempDir, and the hub entirely — the *host's* +/// preview server serves everything through the tunnel. +async fn run_join(args: JoinArgs) -> Result<()> { + let ticket: quarto_p2p::PreviewShareTicket = args.ticket.trim().parse().map_err(|e| { + anyhow::anyhow!( + "invalid join string ({e})\n\ + Expected the `q2preview…` string printed by `q2 preview --share` on the \ + host machine. Copy the whole string — it is long and may wrap across \ + several terminal lines." + ) + })?; + + let host = args.host.unwrap_or_else(|| "127.0.0.1".to_string()); + // An explicit --port gets the friendly availability check. Port 0 / + // absent means the OS assigns one; unlike host mode there is no + // pre-probe — `TunnelClient::bind` reports the port it bound. + if let Some(p) = args.port + && p != 0 + { + validate_explicit_port(&host, p)?; + } + let requested_port = args.port.unwrap_or(0); + let local = tokio::net::lookup_host((host.as_str(), requested_port)) + .await + .ok() + .and_then(|mut addrs| addrs.next()) + .ok_or_else(|| anyhow::anyhow!("could not resolve --host {host}"))?; + + println!(); + println!(" q2 preview — joining a shared session (end-to-end encrypted via iroh)"); + + // The initial dial happens inside `bind`: an unreachable host is a + // clear error right here, not a silent background retry. + let (bound, handle) = + quarto_p2p::TunnelClient::bind(quarto_p2p::TunnelClientConfig::default(), ticket, local) + .await + .map_err(join_bind_error)?; + + let url = format!("http://{bound}/"); + println!(" → {url}"); + println!(); + println!(" Press Ctrl-C to leave the session."); + println!(); + + // Browser-open readiness = the first successful GET /health *through + // the tunnel*. A bare TCP accept (host mode's readiness signal) + // would lie here: the local proxy accepts even when the host is + // unreachable, so only an end-to-end HTTP roundtrip proves the + // session is usable. + if !args.no_browser { + let url_for_open = url.clone(); + tokio::spawn(async move { + const READY_TIMEOUT: Duration = Duration::from_secs(15); + if wait_until_healthy(bound, READY_TIMEOUT).await { + info!(local = %bound, "shared session healthy through the tunnel; opening browser"); + } else { + tracing::warn!( + local = %bound, + timeout_secs = READY_TIMEOUT.as_secs(), + "shared session did not answer /health within the timeout; \ + opening the browser anyway (it may need a manual reload)" + ); + } + open_browser_or_log(&url_for_open, false); + }); + } + + // Report status transitions ("connected via relay", "reconnecting…") + // until Ctrl-C — or fail fast when the host rejects the token. + let mut status = handle.status(); + let status_reporter = async move { + loop { + match *status.borrow_and_update() { + quarto_p2p::TunnelStatus::Connected(kind) => { + println!(" ● connected via {kind}"); + } + quarto_p2p::TunnelStatus::Reconnecting => { + println!(" ○ connection lost — reconnecting…"); + } + quarto_p2p::TunnelStatus::Rejected => { + return Err(anyhow::anyhow!( + "the host rejected this join string — the share session has \ + ended or was restarted with a new string.\n\ + Ask the host for a fresh `q2 preview --share` join string." + )); + } + } + if status.changed().await.is_err() { + // Sender gone = tunnel client shut down; stop reporting. + return Ok(()); + } + } + }; + + let outcome = tokio::select! { + _ = tokio::signal::ctrl_c() => { + println!(); + println!(" Received Ctrl-C, leaving the shared session…"); + Ok(()) + } + reported = status_reporter => reported, + }; + + if let Err(e) = handle.shutdown().await { + tracing::warn!(error = %e, "tunnel client shutdown failed"); + } + outcome +} + +/// Map a tunnel bind failure to actionable CLI guidance. +fn join_bind_error(e: quarto_p2p::TunnelError) -> anyhow::Error { + use quarto_p2p::TunnelError; + match e { + TunnelError::Connect(src) => anyhow::anyhow!( + "could not reach the share host ({src})\n\ + Check that `q2 preview --share` is still running on the host machine \ + and that both machines are online, then retry with the same join string." + ), + TunnelError::Proxy(src) => anyhow::anyhow!( + "could not bind the local proxy port: {src}\n\ + Pass --port 0 to let the OS pick a free port." + ), + other => anyhow::Error::new(other).context("starting the tunnel client"), + } +} + +/// Poll `GET /health` on the local proxy until it answers 200 — i.e. the +/// host's preview hub answered *through the tunnel* — or `total_timeout` +/// elapses. Join mode's readiness gate; same backoff shape and +/// open-anyway-on-timeout contract as [`wait_until_accepting`]. +async fn wait_until_healthy(addr: std::net::SocketAddr, total_timeout: Duration) -> bool { + const INITIAL_BACKOFF: Duration = Duration::from_millis(50); + const MAX_BACKOFF: Duration = Duration::from_secs(1); + // A tunnel roundtrip can legitimately take a relay RTT, but one + // wedged attempt must not eat the whole budget. + const ATTEMPT_TIMEOUT: Duration = Duration::from_secs(5); + + let deadline = tokio::time::Instant::now() + total_timeout; + let mut backoff = INITIAL_BACKOFF; + + loop { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + return false; + } + if let Ok(true) = + tokio::time::timeout(remaining.min(ATTEMPT_TIMEOUT), health_get_ok(addr)).await + { + return true; + } + + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + return false; + } + tokio::time::sleep(backoff.min(remaining)).await; + backoff = (backoff * 8 / 5).min(MAX_BACKOFF); + } +} + +/// One raw HTTP/1.1 `GET /health` against `addr`; `true` iff the status +/// line comes back 200. Hand-rolled so the CLI doesn't grow an HTTP +/// client dependency for a one-line probe. +async fn health_get_ok(addr: std::net::SocketAddr) -> bool { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let Ok(mut stream) = tokio::net::TcpStream::connect(addr).await else { + return false; + }; + if stream + .write_all(b"GET /health HTTP/1.1\r\nHost: q2-preview-join\r\nConnection: close\r\n\r\n") + .await + .is_err() + { + return false; + } + let mut response = Vec::new(); + if stream.read_to_end(&mut response).await.is_err() { + return false; + } + response.starts_with(b"HTTP/1.1 200") +} + /// Bind `host:0`, read the OS-assigned port, drop the listener. /// Returns the port number so the caller can pre-print the URL. fn probe_free_port(host: &str) -> Result { @@ -785,4 +989,72 @@ mod tests { ); late.abort(); } + + // ────────────────────────────────────────────────────────────── + // Phase 3 (bd-6y0p1bne): wait_until_healthy — join mode's + // browser-open gate is an HTTP /health roundtrip through the local + // proxy, not a bare TCP accept (which the proxy always grants, + // even when the tunnel's far side is gone). + // ────────────────────────────────────────────────────────────── + + /// Serve a fixed HTTP/1.1 response to every connection on a fresh + /// loopback port; returns the bound address. Task dies with the test. + async fn spawn_canned_http(response: &'static str) -> std::net::SocketAddr { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind canned server"); + let addr = listener.local_addr().expect("local_addr"); + tokio::spawn(async move { + loop { + let Ok((mut sock, _)) = listener.accept().await else { + break; + }; + tokio::spawn(async move { + let mut buf = [0u8; 1024]; + let _ = sock.read(&mut buf).await; + let _ = sock.write_all(response.as_bytes()).await; + }); + } + }); + addr + } + + #[tokio::test] + async fn wait_until_healthy_true_on_200() { + let addr = spawn_canned_http( + "HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok", + ) + .await; + assert!( + wait_until_healthy(addr, std::time::Duration::from_secs(5)).await, + "a 200 /health must count as ready" + ); + } + + #[tokio::test] + async fn wait_until_healthy_false_on_non_200() { + // A reachable server that answers 503 (e.g. the proxy is up but + // the host hub is not) must NOT count as ready. + let addr = spawn_canned_http( + "HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ) + .await; + let timeout = std::time::Duration::from_millis(300); + let start = std::time::Instant::now(); + let ready = wait_until_healthy(addr, timeout).await; + assert!(!ready, "non-200 answers must not count as healthy"); + assert!( + start.elapsed() >= timeout, + "must keep polling until the deadline in case health recovers" + ); + } + + #[tokio::test] + async fn wait_until_healthy_false_when_nothing_listening() { + let port = reserve_free_port(); + let addr: std::net::SocketAddr = format!("127.0.0.1:{port}").parse().unwrap(); + let ready = wait_until_healthy(addr, std::time::Duration::from_millis(200)).await; + assert!(!ready, "no listener → not healthy"); + } } diff --git a/crates/quarto/src/main.rs b/crates/quarto/src/main.rs index 234255709..a3a0cea39 100644 --- a/crates/quarto/src/main.rs +++ b/crates/quarto/src/main.rs @@ -251,10 +251,18 @@ enum Commands { /// Join a shared preview session using the `q2preview…` string /// printed by `q2 preview --share` on the host machine. - // Hidden until the guest path lands (live-share plan Phase 3, - // bd-6y0p1bne); declared now so the --share conflict is real. - // Phase 3 unhides it and adds its full conflict matrix. - #[arg(long, value_name = "TICKET", conflicts_with = "share", hide = true)] + /// + /// Runs a local proxy for the host's session — no local project + /// is read and nothing is written to disk on this machine, so + /// the host-mode flags (a path, --share, --no-project, + /// --allow-edit, --data-dir, --preview-dir) don't combine with + /// it. --port/--host pick where the local proxy listens; + /// --no-browser still applies. + #[arg( + long, + value_name = "TICKET", + conflicts_with_all = ["path", "share", "no_project", "allow_edit", "data_dir", "preview_dir"] + )] join: Option, }, @@ -801,6 +809,95 @@ mod cli_parse_tests { }; assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict); } + + // ── Phase 3 (bd-6y0p1bne): `--join` conflict matrix ────────────── + // The guest path has no local project, hub, or disk surface, so + // every host-mode-only flag must be a hard parse error, not a + // silent no-op. (`--ui` joins this matrix in Phase 4, when the + // flag itself lands.) + + /// Assert argv is rejected specifically as an argument conflict + /// (not, say, an unknown-arg error). + fn assert_join_conflict(args: &[&str]) { + let err = match try_parse(args) { + Ok(_) => panic!("{args:?} mixes guest mode with a host-only flag; must conflict"), + Err(e) => e, + }; + assert_eq!( + err.kind(), + clap::error::ErrorKind::ArgumentConflict, + "args: {args:?}" + ); + } + + #[test] + fn preview_join_parses_and_captures_ticket() { + let Commands::Preview { join, .. } = parse_preview(&["preview", "--join", "q2previewabc"]) + else { + unreachable!() + }; + assert_eq!(join.as_deref(), Some("q2previewabc")); + } + + #[test] + fn preview_join_conflicts_with_positional_path() { + assert_join_conflict(&["preview", "some/project", "--join", "x"]); + } + + #[test] + fn preview_join_conflicts_with_share() { + assert_join_conflict(&["preview", "--join", "x", "--share"]); + } + + #[test] + fn preview_join_conflicts_with_no_project() { + assert_join_conflict(&["preview", "--join", "x", "--no-project"]); + } + + #[test] + fn preview_join_conflicts_with_allow_edit() { + assert_join_conflict(&["preview", "--join", "x", "--allow-edit"]); + } + + #[test] + fn preview_join_conflicts_with_data_dir() { + assert_join_conflict(&["preview", "--join", "x", "--data-dir", "d"]); + } + + #[test] + fn preview_join_conflicts_with_preview_dir() { + assert_join_conflict(&["preview", "--join", "x", "--preview-dir", "d"]); + } + + #[test] + fn preview_join_composes_with_guest_flags() { + // `--port` picks the local proxy port, `--host` its bind + // interface, `--no-browser` suppresses the auto-open — all + // meaningful for a guest and must keep parsing. + let Commands::Preview { + join, + port, + host, + no_browser, + .. + } = parse_preview(&[ + "preview", + "--join", + "q2previewabc", + "--port", + "9280", + "--host", + "127.0.0.1", + "--no-browser", + ]) + else { + unreachable!() + }; + assert_eq!(join.as_deref(), Some("q2previewabc")); + assert_eq!(port, Some(9280)); + assert_eq!(host.as_deref(), Some("127.0.0.1")); + assert!(no_browser); + } } fn main() -> Result<()> { @@ -867,23 +964,28 @@ fn main() -> Result<()> { share, join, } => { - if join.is_some() { - anyhow::bail!( - "`q2 preview --join` is not implemented yet \ - (guest support lands with Phase 3 of the live-share plan)" - ); + if let Some(ticket) = join { + // Guest mode (live-share plan Phase 3): clap has already + // rejected every host-mode flag via conflicts_with_all. + commands::preview::execute_join(commands::preview::JoinArgs { + ticket, + port, + host, + no_browser, + }) + } else { + commands::preview::execute(commands::preview::PreviewArgs { + path, + port, + host, + no_browser, + data_dir, + preview_dir, + no_project, + allow_edit, + share, + }) } - commands::preview::execute(commands::preview::PreviewArgs { - path, - port, - host, - no_browser, - data_dir, - preview_dir, - no_project, - allow_edit, - share, - }) } Commands::Serve { .. } => commands::serve::execute(), Commands::Create { From 974b8ed6b1aa3518b09d654fe6713db1903ec05a Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Thu, 6 Aug 2026 11:29:12 +0100 Subject: [PATCH 07/22] =?UTF-8?q?docs(plan):=20Phase=203=20cross-machine?= =?UTF-8?q?=20e2e=20record=20=E2=80=94=20GH-Actions=20guest=20over=20the?= =?UTF-8?q?=20real=20n0=20relay=20(bd-6y0p1bne)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run 31092359776: real q2 --join guest on ubuntu-latest against a live --share host on the dev machine. 'connected via relay' on both concurrent guests, first render 12.7s / ~47.5MB through the relay, live-edit propagation median ~1.0s over 4 bumps, screenshots inspected. Throwaway workflow + secret + remote branch cleaned up. --- .../2026-08-03-q2-preview-live-share-iroh.md | 60 ++++++++++++++++--- 1 file changed, 51 insertions(+), 9 deletions(-) diff --git a/claude-notes/plans/2026-08-03-q2-preview-live-share-iroh.md b/claude-notes/plans/2026-08-03-q2-preview-live-share-iroh.md index 7f63b7727..bb58d4055 100644 --- a/claude-notes/plans/2026-08-03-q2-preview-live-share-iroh.md +++ b/claude-notes/plans/2026-08-03-q2-preview-live-share-iroh.md @@ -987,20 +987,17 @@ then went green with the implementation)* 29 ms exit when Connected, 3.0 s when Reconnecting (iroh's documented graceful-close budget with a dial in flight — accepted))* -- [ ] **End-to-end (mandatory, record here):** cross-machine host/guest run +- [x] **End-to-end (mandatory, record here):** cross-machine host/guest run with the real n0 relay path (netns is Linux-only — same logistics as Gate 0 Q3: two physical machines); inspect rendered output in the guest browser; note "verified in browser". Join **two guests concurrently** at least once (v1 scope is N guests — architecture scope note) - *(single-machine leg executed 2026-08-06 with two concurrent guests — - see "Phase 3 end-to-end record" below; the **cross-machine n0-relay - leg is still open** — it needs a second physical machine (user-driven, - runbook analogous to Gate 0's) or a GH-Actions guest like Gate 0 Q3 - (needs push approval). The "connected via relay" message rendering - is exercised only by that leg (local runs select the direct path); - the wrapped-ticket triple-click copy check also still wants a human - terminal)* + *(both legs done: single-machine 2026-08-06 with two concurrent + guests, cross-machine 2026-08-06 via a GH-Actions guest — see the + records below. The only remaining human-eyeball nicety is the + wrapped-ticket triple-click copy check in a real terminal — + non-blocking, same status as Phase 2's note)* ### Phase 3 end-to-end record (single-machine legs, 2026-08-06) @@ -1049,6 +1046,51 @@ dir. --skip-hub-build` → "All verification steps passed!"; `cargo tree -i iroh` from `wasm-quarto-hub-client` still fails (closure clean). +### Phase 3 end-to-end record (cross-machine n0-relay leg, 2026-08-06) + +Executed via a **GH-Actions guest**, same logistics as Gate 0 Q3: live +host on the dev machine (`q2 preview --share --no-browser` +— fixture with `MARKER-0`, plus a loop bumping `MARKER-N` every 20 s +and logging host bump timestamps in epoch-ms), guest = `ubuntu-latest` +runner (Azure network) that builds **the real `q2` binary** and joins +with **the real `q2 preview --join`** — no spike shims anywhere. +Throwaway workflow `spike-p3-join-guest.yml` + driver +`spike/p3-guest-driver.mjs` on branch `spike/bd-6y0p1bne-p3-cross-e2e` +(commit 07d945a1, never merged); ticket passed through the ephemeral +`SPIKE_P3_TICKET` repo secret. **GH run 31092359776** (job green in +12m13s, most of it the q2 build). All output and both screenshots +downloaded from the `p3-guest-evidence` artifact and inspected. + +- **CLI status surface:** each guest printed exactly one + `● connected via relay` — the Azure↔residential pair never + hole-punched a direct path (exactly the relay-fallback scenario this + leg exists for), and zero `reconnecting`/`rejected` events over the + whole session. This is the leg that exercises the + `PathKind::Relay` rendering (local runs only ever show `direct + connection`). +- **`/health` through the tunnel** (both guests, guest2 concurrent + with guest1): correct payload with the host's + `"index_document_id":"4JGt98WMiAbWRuCfaDwmp3NmPuga"`, + `"qmd_file_count":2`. +- **Browser (headless Chromium on the runner):** first render through + the real n0 relay in **12.7 s**, ~**47.5 MB** fetched (uncompressed + WASM dominates — same payload confounder as Gate 0; its + HTTP-compression mitigation remains the first lever). Boot + screenshot shows `MARKER-33` rendered; final shows `MARKER-37`. +- **Live-edit propagation over 4 marker bumps** (runner-observed ts − + host bump ts; includes the driver's 150 ms poll grain and + host↔runner NTP skew): **899 / 979 / 1043 / 1563 ms, median + ~1.0 s** — consistent with Gate 0's 0.81–2.34 s and the local leg. +- **n0 relays from the runner:** euc1-1 and use1-1 probed 200/OK in + 0.57 s / 0.32 s. +- Caveats unchanged from Gate 0: runner egress is datacenter-class, + not residential; a human-driven Safari-app/lid-close session remains + an optional extra. +- **Housekeeping done 2026-08-06:** `SPIKE_P3_TICKET` secret deleted, + remote `spike/bd-6y0p1bne-p3-cross-e2e` branch deleted (workflow was + throwaway, never merged; local branch kept for reference), host + process stopped — the session token died with it. + ## Phase 4 — `q2 preview --ui editor` (independent track) Serve the **full hub-client editor** from the preview server instead of the From 9dfce50e32953382ff543ae0bef9b257bf29a796 Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Thu, 6 Aug 2026 13:52:45 +0100 Subject: [PATCH 08/22] =?UTF-8?q?feat(preview):=20Phase=204=20=E2=80=94=20?= =?UTF-8?q?q2=20preview=20--ui=20editor=20(bd-jt1etjbn)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Serve the full hub-client editor from the preview server via --ui (clap ValueEnum, default viewer; conflicts with --join). The editor boots into the hub-client share route (#/share/{docId}?server=%2Fws&file=…&name=…) built in an on_ready closure — the index doc id only exists server-side, so editor mode defers the URL print + browser-open gate into the callback. UI × write policy stays a strict 2×2: --ui editor without --allow-edit is the sandbox mode (session edits sync live, disk stays authoritative) and prints the ephemeral-edits note; the DiskWritePolicy mapping is untouched by the UI choice. Embedding: hub-client's new build:preview-embed script (auth off, sync server pinned to relative /ws, PWA service worker disabled via new VITE_DISABLE_PWA so ephemeral origins don't precache ~67 MB) emits dist-preview-embed/, built by the new cargo xtask build-hub-client-embed. quarto-preview's build.rs embeds a filtered copy: files byte-identical to the viewer dist at the same rel path are stripped (64/187 files, 45.7 MB incl. the 38.4 MB wasm) and served through the viewer embed by the runtime editor→viewer fallback. Measured release q2 delta: +22.2 MB (vs ~+69.6 MB naive double-embed). Placeholder fallback (naming the xtask) keeps unbuilt trees working. Tests first (observed failing as the structural compile errors, per the Phase 2 precedent): CLI parse/conflict tests, boot-URL builder + file-picker units, write-policy 2×2 sweep, embed-contract units, and an editor-mode server integration test. Workspace suite 10914 passed; full cargo xtask verify green; browser e2e (both --allow-edit legs) recorded in the plan. Plan: claude-notes/plans/2026-08-03-q2-preview-live-share-iroh.md --- .../2026-08-03-q2-preview-live-share-iroh.md | 157 ++++++++- crates/quarto-preview/build.rs | 142 ++++++++- crates/quarto-preview/src/lib.rs | 254 ++++++++++++++- .../quarto-preview/tests/integration/boot.rs | 1 + .../tests/integration/config_endpoint.rs | 1 + .../diagnostics_capture_failure.rs | 1 + .../tests/integration/diagnostics_endpoint.rs | 1 + .../tests/integration/eager_capture.rs | 2 + .../tests/integration/editor_ui.rs | 125 ++++++++ .../tests/integration/join_tunnel.rs | 1 + .../quarto-preview/tests/integration/main.rs | 1 + .../tests/integration/render_scripts_boot.rs | 1 + .../tests/integration/staleness.rs | 1 + crates/quarto/src/commands/preview.rs | 300 +++++++++++++++--- crates/quarto/src/main.rs | 111 ++++++- crates/xtask/src/build_hub_client_embed.rs | 58 ++++ crates/xtask/src/main.rs | 11 + hub-client/.gitignore | 1 + hub-client/package.json | 1 + hub-client/vite.config.ts | 10 +- 20 files changed, 1104 insertions(+), 76 deletions(-) create mode 100644 crates/quarto-preview/tests/integration/editor_ui.rs create mode 100644 crates/xtask/src/build_hub_client_embed.rs diff --git a/claude-notes/plans/2026-08-03-q2-preview-live-share-iroh.md b/claude-notes/plans/2026-08-03-q2-preview-live-share-iroh.md index bb58d4055..0df497de3 100644 --- a/claude-notes/plans/2026-08-03-q2-preview-live-share-iroh.md +++ b/claude-notes/plans/2026-08-03-q2-preview-live-share-iroh.md @@ -1093,6 +1093,15 @@ downloaded from the `p3-guest-evidence` artifact and inspected. ## Phase 4 — `q2 preview --ui editor` (independent track) +*(implemented 2026-08-06, bd-jt1etjbn; tests landed first and were +observed failing — 20+ E0432/E0425/E0560 compile errors on the +not-yet-existing API (`PreviewUi`, `PreviewUiArg`, `EMBEDDED_EDITOR`, +`lookup_embedded`, `build_editor_boot_url`, `pick_editor_file`, +`editor_ephemeral_note`, `PreviewConfig::ui`), the expected failure +mode for a structural addition per the Phase 2 E0026 precedent — +then went green with the implementation, first against the editor +placeholder, then against the real embed)* + Serve the **full hub-client editor** from the preview server instead of the read-only SPA. Zero hub-client *source* changes expected — we reuse the `#/share/` route with a relative `server` (hub-client resolves relative sync @@ -1102,32 +1111,60 @@ required: `App.tsx:418-423`). **Tests first:** -- [ ] CLI: `--ui viewer` / `--ui editor` parse (clap `ValueEnum`, default +- [x] CLI: `--ui viewer` / `--ui editor` parse (clap `ValueEnum`, default `viewer`); an unknown value (`--ui monaco`) is rejected with the list of valid values; **`--ui` × `--join` rejected** (the one Phase 3 conflict-matrix entry deferred here because the flag didn't exist yet — extend `--join`'s `conflicts_with_all` in `main.rs` and add the parse test alongside Phase 3's `assert_join_conflict` helper) -- [ ] Rust unit: `--ui editor` boot URL builder emits + *(6 tests in `cli_parse_tests`, incl. the `--share --ui editor + --allow-edit` composition; the conflict pinned as + `ErrorKind::ArgumentConflict`)* +- [x] Rust unit: `--ui editor` boot URL builder emits `http://{host}:{port}/#/share/{indexDocId}?server=%2Fws&file={rel}&name={project}` (doc id **without** the `automerge:` prefix — `routing.ts:420`; `file` falls back to the first `.qmd` when no initial page was resolved; note params ride the **hash fragment**, not the URL query) -- [ ] Rust unit: `--ui editor` leaves the write policy alone: with + *(`build_editor_boot_url` + `pick_editor_file` tests in + `commands/preview.rs`; the fallback takes the lexicographic min of + the index's `.qmd` paths so the unordered files map can't make the + boot nondeterministic; param values go through a new + `percent_encode_component` — also encodes `/`, matching what + `URLSearchParams.toString()` emits on the client)* +- [x] Rust unit: `--ui editor` leaves the write policy alone: with `--allow-edit` → `DiskWritePolicy::WriteBack`; without → `DiskWritePolicy::ReadOnly` **and** the ephemeral-session note is emitted ("session edits are ephemeral — pass `--allow-edit` to persist to disk") — assert via an injected writer/callback, not stdout scraping (same style as Phase 2's ticket-line test) -- [ ] Build-time: embed-dir placeholder fallback works on a tree without the + *(`ui_choice_never_changes_disk_write_policy` sweeps the full 2×2 + through the real `build_hub_config`; the note is the pure function + `editor_ephemeral_note` (tested directly — the string logic, not + stdout), printed by the CLI when editor && !allow-edit)* +- [x] Build-time: embed-dir placeholder fallback works on a tree without the editor dist (mirror the existing `QUARTO_PREVIEW_EMBED_DIR` placeholder test story, `crates/quarto-preview/build.rs:19-43,60-107`) -- [ ] `cd hub-client && npm run build:all` still green (CRITICAL per + *(the embed tests ran green in both tree states — placeholder + (before `dist-preview-embed/` existed) and real embed; the editor + placeholder carries `
` like the viewer's and names + `cargo xtask build-hub-client-embed`. Deliberate deviation: no + `cargo:warning` for the missing editor dist — unlike the viewer + it is opt-in, and warning on every build would nag everyone who + never uses `--ui editor`; the placeholder page is the signal. + Plus 4 embed-contract unit tests in `lib.rs` (editor index served + from the editor embed; viewer never reads the editor embed; + shared assets fall back to the viewer's bytes; **no file in the + editor embed is byte-identical to the viewer embed at the same + path** — the strip contract) and a server-tier integration test + `editor_ui.rs` pinning config → OnceLock → handler plumbing)* +- [x] `cd hub-client && npm run build:all` still green (CRITICAL per CLAUDE.md); new embed build produces a servable dist + *(covered by the full `cargo xtask verify` recorded below; the + embed dist was served for real in the e2e session)* **Implementation:** -- [ ] hub-client: `build:preview-embed` npm script — no `VITE_GOOGLE_CLIENT_ID` +- [x] hub-client: `build:preview-embed` npm script — no `VITE_GOOGLE_CLIENT_ID` (auth UI off: `App.tsx:104`), `VITE_DEFAULT_SYNC_SERVER=/ws`, outDir `dist-preview-embed/` via `vite build --outDir`. Note: hub-client has **no** alternate-config/outDir precedent to copy — the only prior art is @@ -1136,14 +1173,29 @@ required: `App.tsx:418-423`). entry points (main, debug, q2-debug, q2-preview) and run after the `build:wasm` + `build:sandboxed` pre-steps. **hub-client change ⇒ two-commit changelog rule** -- [ ] xtask: `build-hub-client-embed` (sibling of `build_q2_preview_spa.rs`) -- [ ] `quarto-preview/build.rs`: second build-script-**emitted** env + *(done; same vite.config.ts, so all 4 entries + the public/ copies + survive. One addition beyond the plan: `VITE_DISABLE_PWA=1` + a + matching conditional in vite.config.ts — the PWA service worker + would precache the whole ~67 MB bundle (WASM included) into Cache + Storage for every ephemeral `q2 preview` origin (each random port + is its own origin), so the embed build disables it; normal builds + are untouched. `dist-preview-embed` added to hub-client/.gitignore + — the bare `dist` entry doesn't match it)* +- [x] xtask: `build-hub-client-embed` (sibling of `build_q2_preview_spa.rs`) +- [x] `quarto-preview/build.rs`: second build-script-**emitted** env (`cargo:rustc-env=QUARTO_HUB_CLIENT_EMBED_DIR=…` — mirroring how `QUARTO_PREVIEW_EMBED_DIR` actually works: build.rs emits it at :30-33, nothing reads it from the environment) + `include_dir!` + placeholder fallback; runtime: `--ui editor` flips which dir `spa_handler` (`lib.rs:490-509`) serves -- [ ] `--ui` flag (clap `ValueEnum` `PreviewUi { Viewer, Editor }`, default + *(done; the emitted dir is an OUT_DIR **filtered copy** of + `dist-preview-embed/` — see the dedupe item. Runtime seam is + `lookup_embedded(ui, rel)`: viewer mode reads only `EMBEDDED_SPA` + (Phase-A behavior byte for byte); editor mode reads + `EMBEDDED_EDITOR` then falls back to `EMBEDDED_SPA`. A + `--preview-dir` disk override keeps taking precedence over both + embeds regardless of `--ui`, unchanged semantics)* +- [x] `--ui` flag (clap `ValueEnum` `PreviewUi { Viewer, Editor }`, default `Viewer`) → boot URL in share-route form when `editor`. **Structural change required:** today the CLI builds *and prints* the boot URL and captures it in the browser-open task (`preview.rs:136-141`, :158-180) @@ -1155,7 +1207,17 @@ required: `App.tsx:418-423`). print + browser-open path — "available in `on_ready`" is not enough by itself. No write-policy coupling — without `--allow-edit`, emit the ephemeral-session note instead of flipping `DiskWritePolicy` -- [ ] Dedupe the shared `wasm_quarto_hub_client_bg.wasm` across the two + *(done via the channel-back option: the enum lives in quarto-preview + (`PreviewUi`, no clap dep) with a CLI-side `PreviewUiArg` ValueEnum + mirror; editor mode calls the already-public + `quarto_preview::run_with_on_ready` with a CLI closure that reads + `ctx.index().document_id()` + `get_all_files()`, builds the URL, + prints it, and gates the browser-open on the same + `wait_until_accepting` probe viewer mode uses. Degenerate case: + no `.qmd` in the index (e.g. `--no-project`) boots to the plain + editor project selector with a warning instead of a broken share + link. Viewer mode's print/open path is untouched)* +- [x] Dedupe the shared `wasm_quarto_hub_client_bg.wasm` across the two embeds. **Decided from measured numbers:** the artifact is 38,371,765 bytes and **byte-identical** in both dists (sha256 `a075c962…`), and Vite's content hashing even gives it the same @@ -1167,13 +1229,84 @@ required: `App.tsx:418-423`). "strip from one dist, route both asset paths to the shared copy" the natural mechanism (exact design in this phase). Still record the final binary delta after dedupe -- [ ] Known warts to document in `--help` + here: hub-client persists a + *(done, generalized: build.rs strips **every** file byte-identical + to the real viewer dist at the same rel path (not just the wasm), + and the runtime editor→viewer fallback serves those paths from the + viewer embed. Measured on the real dists: 64 of 187 files stripped, + 45,705,880 of 69,580,949 bytes — the 38.4 MB main wasm, a 3.3 MB + sass chunk, the 2.7 MB automerge wasm, tree-sitter wasm, KaTeX + fonts — leaving 23.9 MB embedded. **Release `q2` binary: + 153,127,632 B without the editor dist → 175,353,280 B with it = + +22.2 MB** (vs ~+69.6 MB naive; ~47 MB saved). Safety property: + only byte-identical files are stripped, so two dists built from + *different* wasm artifacts embed both copies — fat but correct. + Operational note: `build:preview-embed` reruns `build:wasm`, and a + regenerated wasm can differ from the one the viewer dist carries + (observed: sha `a075c962…` → `6a265987…` with no Rust change in + the WASM closure) — if the hashes diverge, rebuild the viewer + (`cargo xtask build-q2-preview-spa`) so the strip fires again)* +- [x] Known warts to document in `--help` + here: hub-client persists a ProjectEntry + IndexedDB automerge cache per ephemeral session (stale entries accumulate across preview restarts — follow-up strand); `--share --ui editor` means the *host* picks the UI for all guests -- [ ] End-to-end (mandatory): real browser session — editor loads, file + *(both in the `--ui` help text. One more wart found in the e2e: + on a fresh origin — which every `q2 preview` port is — the share + route's auto-added project entry trips hub-client's **project-set + migration screen** ("Upgrade: Synced Project List"); one click on + "Migrate Projects" proceeds, and the set syncs through the + preview's own `/ws`. App.tsx:240-252 already auto-establishes the + set silently for `join-collection` invites; extending that to + `share` routes would change production hub-client behavior, so it + goes to the follow-up strand with the IndexedDB cleanup, not into + this phase)* +- [x] End-to-end (mandatory): real browser session — editor loads, file sidebar shows the project, Monaco edit persists to host disk (verify file content on disk), preview pane updates + *(executed 2026-08-06 — see "Phase 4 end-to-end record" below)* + +### Phase 4 end-to-end record (2026-08-06) + +All output inspected. Binary: `target/debug/q2` at the Phase 4 tree with +the real embeds (viewer + editor rebuilt from the same wasm artifact). +Fixture: `_quarto.yml` + `index.qmd` (`MARKER-0 here.`) + `about.qmd` in +a scratchpad dir. Browser: Playwright Chromium, screenshots inspected +for both legs. + +- **Write-back leg:** `q2 preview --ui editor --allow-edit + --no-browser --port 9393` printed + `→ http://127.0.0.1:9393/#/share/269NF7Cd3ECERWph17EyzsTBmhuW?server=%2Fws&file=index.qmd&name=fixture` + (no ephemeral note — `--allow-edit` given). Browser: project-set + migration screen (fresh origin, the wart above) → one click → + **editor loaded (Monaco surface) in 0.48 s**; file sidebar lists + `_quarto.yml`, `about.qmd`, `index.qmd` + outline; preview pane + rendered `MARKER-0 here.`. Typed ` EDITED-BY-P4-E2E` at the end of + the marker line in Monaco → preview pane re-rendered live (checked in + child frames only — the iframe finding from Gate 0) → **the edit + landed in `index.qmd` on disk ~3 s later** (rides the hub's 5 s + periodic sync): `MARKER-0 here. EDITED-BY-P4-E2E`. Before/after + screenshots show Monaco + preview pane both carrying the edit. +- **Sandbox leg (the 2×2's other corner):** same fixture reset, `q2 + preview --ui editor --no-browser --port 9394` (no + `--allow-edit`) printed the note `session edits are ephemeral — pass + --allow-edit to persist edits to disk`, then the share-route URL. + Editor loaded (0.47 s), typed ` SANDBOX-EDIT-P4`, preview pane + re-rendered live, and **`index.qmd` on disk stayed byte-identical + across a 12 s window** (two periodic-sync intervals) — the + ReadOnly policy held while the live session carried the edit. +- **Editor-mode HTTP surface** (server-tier test + placeholder-tree + run): `GET /` serves the editor embed's `index.html`, unknown paths + fall back to it, `/health` + `/api/preview/config` keep answering + from the hub. On the placeholder tree the same invocation served the + "editor UI is not built" page naming the xtask (observed live at + port 9391 before the embed was built). +- **Verification at this tree:** `cargo build --workspace` green; + `cargo nextest run --workspace` **10914 passed** (incl. the 17 new + Phase 4 tests, run against both the placeholder and the real embed); + clippy clean on the three touched crates; **full `cargo xtask verify` + (hub-client legs included, per this phase's policy) → "All + verification steps passed!"**, output inspected. After verify's own + `build:wasm` re-run, the two dists' wasm stayed byte-identical + (sha `6a265987…` in both) — the dedupe precondition holds. ## Phase 5 — Spike: `--join https://quarto-hub.com/#/share/…` diff --git a/crates/quarto-preview/build.rs b/crates/quarto-preview/build.rs index 1d348c76c..c9d23ed88 100644 --- a/crates/quarto-preview/build.rs +++ b/crates/quarto-preview/build.rs @@ -1,18 +1,29 @@ -//! Build script that locates the q2-preview-spa bundle at compile time. +//! Build script that locates the SPA bundles embedded at compile time. //! -//! Mirrors `crates/quarto-trace-server/build.rs` exactly. The -//! `include_dir!` macro needs a concrete compile-time path; this script -//! resolves it: +//! Mirrors `crates/quarto-trace-server/build.rs` for the viewer SPA. +//! The `include_dir!` macro needs concrete compile-time paths; this +//! script resolves two of them: //! -//! 1. If `q2-preview-spa/dist/index.html` exists, embed that directory. -//! 2. Otherwise, write a placeholder `index.html` into the crate's -//! `OUT_DIR` and embed that, so the build still succeeds. The -//! placeholder tells the user to run `cargo xtask build-q2-preview-spa` -//! (added in A.4 / bd-501n). +//! 1. **Viewer** (`QUARTO_PREVIEW_EMBED_DIR`): the q2-preview SPA. If +//! `q2-preview-spa/dist/index.html` exists, embed that directory; +//! otherwise embed a placeholder `index.html` pointing at +//! `cargo xtask build-q2-preview-spa` (A.4 / bd-501n). +//! 2. **Editor** (`QUARTO_HUB_CLIENT_EMBED_DIR`): the full hub-client +//! editor served by `q2 preview --ui editor` (live-share plan +//! Phase 4, bd-jt1etjbn). If `hub-client/dist-preview-embed/` +//! exists, embed a *filtered copy*: files byte-identical to the +//! real viewer dist at the same relative path are stripped, because +//! the runtime lookup serves those paths from the viewer embed — +//! that is how the ~38 MB `wasm_quarto_hub_client_bg-*.wasm` (plus +//! the automerge/tree-sitter wasm and shared fonts, all with +//! content-hashed names identical across the two Vite builds) is +//! embedded once instead of twice. Otherwise embed a placeholder +//! pointing at `cargo xtask build-hub-client-embed`. No cargo +//! warning for the missing editor dist — unlike the viewer it is +//! opt-in (`--ui editor`), and the placeholder page names the fix. //! -//! The chosen path is exposed to the crate as -//! `QUARTO_PREVIEW_EMBED_DIR` via `cargo:rustc-env`, and `src/lib.rs` -//! consumes it via `include_dir!("$QUARTO_PREVIEW_EMBED_DIR")`. +//! Both paths are exposed via `cargo:rustc-env` and consumed by +//! `src/lib.rs` through `include_dir!("$VAR")`. use std::path::{Path, PathBuf}; @@ -21,7 +32,8 @@ fn main() { let workspace_root = manifest_dir.join("..").join(".."); let real_dist = workspace_root.join("q2-preview-spa").join("dist"); - let embed_dir = if real_dist.join("index.html").is_file() { + let viewer_is_real = real_dist.join("index.html").is_file(); + let embed_dir = if viewer_is_real { real_dist.clone() } else { make_placeholder_dist() @@ -40,6 +52,25 @@ fn main() { if real_dist.is_dir() { watch_recursive(&real_dist); } + + // Editor embed (Phase 4). The viewer dist is only a dedupe target + // when it is what the viewer embed actually serves — stripping + // against a dist that isn't embedded would 404 the shared assets. + let editor_dist = workspace_root.join("hub-client").join("dist-preview-embed"); + let editor_embed_dir = if editor_dist.join("index.html").is_file() { + let dedupe_against = viewer_is_real.then_some(real_dist.as_path()); + make_editor_embed(&editor_dist, dedupe_against) + } else { + make_editor_placeholder() + }; + println!( + "cargo:rustc-env=QUARTO_HUB_CLIENT_EMBED_DIR={}", + editor_embed_dir.display() + ); + println!("cargo:rerun-if-changed={}", editor_dist.display()); + if editor_dist.is_dir() { + watch_recursive(&editor_dist); + } } fn watch_recursive(root: &Path) { @@ -57,6 +88,48 @@ fn watch_recursive(root: &Path) { } } +/// Produce the editor embed directory in `OUT_DIR`: a copy of +/// `editor_dist` minus every file that is byte-identical to +/// `dedupe_against` at the same relative path (those are served through +/// the viewer embed at runtime). Rebuilt from scratch on every rerun so +/// deleted/renamed dist files can never linger in the embed. +fn make_editor_embed(editor_dist: &Path, dedupe_against: Option<&Path>) -> PathBuf { + let out_dir = PathBuf::from(std::env::var("OUT_DIR").unwrap()); + let embed = out_dir.join("editor-embed"); + if embed.exists() { + std::fs::remove_dir_all(&embed).expect("clear stale editor embed"); + } + std::fs::create_dir_all(&embed).expect("create editor embed dir"); + copy_filtered(editor_dist, editor_dist, &embed, dedupe_against); + embed +} + +fn copy_filtered(root: &Path, dir: &Path, embed: &Path, dedupe_against: Option<&Path>) { + let entries = match std::fs::read_dir(dir) { + Ok(it) => it, + Err(_) => return, + }; + for entry in entries.flatten() { + let path = entry.path(); + let rel = path.strip_prefix(root).expect("entry under walk root"); + if entry.file_type().is_ok_and(|t| t.is_dir()) { + copy_filtered(root, &path, embed, dedupe_against); + continue; + } + let bytes = std::fs::read(&path).expect("read editor dist file"); + if let Some(viewer) = dedupe_against + && std::fs::read(viewer.join(rel)).is_ok_and(|v| v == bytes) + { + continue; // shared with the viewer embed; served from there + } + let dest = embed.join(rel); + if let Some(parent) = dest.parent() { + std::fs::create_dir_all(parent).expect("create embed subdir"); + } + std::fs::write(&dest, bytes).expect("write embed file"); + } +} + fn make_placeholder_dist() -> PathBuf { let out_dir = PathBuf::from(std::env::var("OUT_DIR").unwrap()); let dist = out_dir.join("placeholder-dist"); @@ -106,6 +179,49 @@ cargo build -p quarto dist } +fn make_editor_placeholder() -> PathBuf { + let out_dir = PathBuf::from(std::env::var("OUT_DIR").unwrap()); + let dist = out_dir.join("editor-placeholder-dist"); + std::fs::create_dir_all(&dist).expect("create editor placeholder dist dir"); + + let index = dist.join("index.html"); + // Same `
` contract as the viewer placeholder: even + // on an unbuilt tree, `--ui editor` boots to a page with the React + // mount point and instructions. + let html = r#" + + + + q2 preview — editor UI not built + + + +
+

The q2 preview editor UI is not built

+

+ The embedded hub-client editor bundle is a placeholder. Build + the editor and rebuild the quarto binary: +

+
cargo xtask build-hub-client-embed
+cargo build -p quarto
+

+ Or run without --ui editor to use the default + read-only preview UI. +

+
+ + +"#; + write_if_changed(&index, html); + + dist +} + fn write_if_changed(path: &Path, contents: &str) { let existing = std::fs::read_to_string(path).ok(); if existing.as_deref() != Some(contents) { diff --git a/crates/quarto-preview/src/lib.rs b/crates/quarto-preview/src/lib.rs index 90c998263..2a9157e37 100644 --- a/crates/quarto-preview/src/lib.rs +++ b/crates/quarto-preview/src/lib.rs @@ -38,6 +38,14 @@ pub use config::EnginePolicy; /// else a placeholder). static EMBEDDED_SPA: Dir<'_> = include_dir!("$QUARTO_PREVIEW_EMBED_DIR"); +/// The hub-client editor bundle embedded for `--ui editor` (live-share +/// plan Phase 4, bd-jt1etjbn). See `build.rs`: the real +/// `hub-client/dist-preview-embed/` when built (with files +/// byte-identical to the viewer dist stripped — those are served +/// through [`EMBEDDED_SPA`] by [`lookup_embedded`]), else a +/// placeholder pointing at `cargo xtask build-hub-client-embed`. +static EMBEDDED_EDITOR: Dir<'_> = include_dir!("$QUARTO_HUB_CLIENT_EMBED_DIR"); + /// Optional override pointing at a SPA bundle on disk. Set once at /// `run()` start and read on every SPA-fallback invocation. Process- /// wide because the SPA fallback handler is stateless from axum's POV @@ -62,6 +70,28 @@ static RESOURCE_DISK_MAP: OnceLock> = /// above (one preview server per process; nextest isolates tests). static ALLOW_EDIT: OnceLock = OnceLock::new(); +/// Which embedded frontend this session serves (`--ui`, Phase 4 +/// bd-jt1etjbn). Set once at `run()` from [`PreviewConfig::ui`] and +/// read by the SPA fallback handler. Same OnceLock pattern as above. +static PREVIEW_UI: OnceLock = OnceLock::new(); + +/// Which embedded frontend the preview server serves (`--ui`, +/// live-share plan Phase 4, bd-jt1etjbn). +/// +/// The flag *substitutes* which embedded dist the SPA fallback serves — +/// it is not additive — and it never changes the disk write policy: +/// UI × write policy is a real 2×2, so `--ui editor` without +/// `--allow-edit` is a deliberate sandbox mode (guests' edits drive the +/// live session; the host's disk stays authoritative). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum PreviewUi { + /// The read-only preview SPA (q2-preview-spa) — the default. + #[default] + Viewer, + /// The full hub-client editor. + Editor, +} + /// Runtime configuration for the preview server. #[derive(Clone)] pub struct PreviewConfig { @@ -131,6 +161,11 @@ pub struct PreviewConfig { /// Requires a pre-resolved (non-zero) `port` — the CLI probes one /// before calling in. pub share: bool, + /// Which embedded frontend to serve (`--ui`, Phase 4 bd-jt1etjbn): + /// the read-only preview SPA (default) or the full hub-client + /// editor. Orthogonal to `allow_edit` — the UI choice never changes + /// the disk write policy. + pub ui: PreviewUi, } /// Run the preview server. Returns when the server is shut down (ctrl-c @@ -197,6 +232,10 @@ where // `/api/preview/config` handler before the server starts serving. let _ = ALLOW_EDIT.set(config.allow_edit); + // Phase 4 (bd-jt1etjbn): stash the UI choice for the SPA fallback + // handler before the server starts serving. + let _ = PREVIEW_UI.set(config.ui); + let storage = build_storage(&config).context("building storage manager")?; let hub_config = build_hub_config(&config); @@ -544,6 +583,50 @@ async fn preview_config_handler() -> Response { axum::Json(serde_json::json!({ "allowEdit": allow_edit })).into_response() } +/// The UI mode this session serves. Defaults to the viewer when `run()` +/// hasn't stashed a choice (e.g. handler-level tests composing routers +/// directly via [`extend_with_spa`]). +fn current_ui() -> PreviewUi { + PREVIEW_UI.get().copied().unwrap_or_default() +} + +/// Resolve `rel` against the embedded bundles for the given UI mode. +/// +/// Editor mode looks in the editor embed first, then falls back to the +/// viewer embed — the dedupe seam (Phase 4, bd-jt1etjbn): `build.rs` +/// strips editor-dist files byte-identical to the viewer dist, so +/// shared content-hashed assets (most notably the ~38 MB +/// `wasm_quarto_hub_client_bg-*.wasm`) are embedded once, in the viewer +/// bundle. Viewer mode never reads the editor embed. +fn lookup_embedded(ui: PreviewUi, rel: &str) -> Option<&'static include_dir::File<'static>> { + match ui { + PreviewUi::Viewer => EMBEDDED_SPA.get_file(rel), + PreviewUi::Editor => EMBEDDED_EDITOR + .get_file(rel) + .or_else(|| EMBEDDED_SPA.get_file(rel)), + } +} + +/// The editor embed's `index.html`, when present (real dist or +/// placeholder). Pub-visible test seam: the editor-mode integration +/// test asserts the served body equals this, pinning that `--ui editor` +/// actually flips which embed the fallback serves. +pub fn embedded_editor_index_html() -> Option<&'static str> { + EMBEDDED_EDITOR + .get_file("index.html") + .and_then(|f| f.contents_utf8()) +} + +/// The note announced when `--ui editor` runs without `--allow-edit` +/// (the sandbox composition): session edits sync live to every +/// connected client, but the host's disk stays authoritative — a +/// host-side filesystem change converges the document back to disk +/// content (`quarto-hub/src/sync.rs`), and nothing persists. +pub fn editor_ephemeral_note(allow_edit: bool) -> Option<&'static str> { + (!allow_edit) + .then_some("session edits are ephemeral — pass --allow-edit to persist edits to disk") +} + async fn spa_handler(req: axum::http::Request) -> Response { let path = req.uri().path(); let rel = path.trim_start_matches('/'); @@ -553,13 +636,14 @@ async fn spa_handler(req: axum::http::Request) -> Response { return serve_from_disk(override_dir, rel).await; } + let ui = current_ui(); // Try the exact path first (an asset like `assets/index-.js`). - if let Some(file) = EMBEDDED_SPA.get_file(rel) { + if let Some(file) = lookup_embedded(ui, rel) { return asset_response(rel, file.contents().to_vec()); } // SPA fallback: any non-asset path gets `index.html` for client- // side routing. - if let Some(index) = EMBEDDED_SPA.get_file("index.html") { + if let Some(index) = lookup_embedded(ui, "index.html") { return asset_response("index.html", index.contents().to_vec()); } (StatusCode::NOT_FOUND, "no spa").into_response() @@ -594,7 +678,7 @@ async fn serve_spa_index() -> Response { if let Some(Some(override_dir)) = SPA_DIR_OVERRIDE.get() { return serve_from_disk(override_dir, "index.html").await; } - if let Some(index) = EMBEDDED_SPA.get_file("index.html") { + if let Some(index) = lookup_embedded(current_ui(), "index.html") { return asset_response("index.html", index.contents().to_vec()); } (StatusCode::NOT_FOUND, "no spa").into_response() @@ -642,3 +726,167 @@ fn content_type_for(path: &str) -> HeaderValue { }; HeaderValue::from_static(mime) } + +#[cfg(test)] +mod tests { + use super::*; + + // ────────────────────────────────────────────────────────────── + // Phase 4 (bd-jt1etjbn): `--ui` never touches the write policy. + // UI × write policy is a real 2×2 — `--ui editor` without + // `--allow-edit` is the deliberate sandbox mode (session edits + // sync live, disk stays authoritative). + // ────────────────────────────────────────────────────────────── + + fn test_config(ui: PreviewUi, allow_edit: bool) -> PreviewConfig { + PreviewConfig { + host: "127.0.0.1".to_string(), + port: 0, + project_root: None, + single_file: None, + data_dir: PathBuf::from("unused"), + spa_dir_override: None, + engine_registry: None, + engine_policy: EnginePolicy::Manual, + resource_html_files: Vec::new(), + cache_dir: None, + allow_edit, + share: false, + ui, + } + } + + #[test] + fn ui_choice_never_changes_disk_write_policy() { + use quarto_hub::sync::DiskWritePolicy; + for ui in [PreviewUi::Viewer, PreviewUi::Editor] { + let read_only = build_hub_config(&test_config(ui, false)); + assert!( + matches!(read_only.disk_write_policy, DiskWritePolicy::ReadOnly), + "{ui:?} without --allow-edit must stay ReadOnly" + ); + let write_back = build_hub_config(&test_config(ui, true)); + assert!( + matches!(write_back.disk_write_policy, DiskWritePolicy::WriteBack), + "{ui:?} with --allow-edit must write back" + ); + } + } + + #[test] + fn editor_ephemeral_note_emitted_only_without_allow_edit() { + let note = editor_ephemeral_note(false) + .expect("editor without --allow-edit must emit the ephemeral-session note"); + assert!( + note.contains("session edits are ephemeral"), + "note must say what happens: {note}" + ); + assert!( + note.contains("--allow-edit"), + "note must name the fix: {note}" + ); + assert!( + editor_ephemeral_note(true).is_none(), + "no note when edits persist to disk" + ); + } + + // ────────────────────────────────────────────────────────────── + // Phase 4: embedded-editor asset lookup + the wasm dedupe + // contract. The editor embed is stripped of files byte-identical + // to the viewer embed at the same relative path (build.rs), and + // the runtime lookup routes those paths to the viewer's copy — + // that is how one ~38 MB WASM serves both frontends. + // ────────────────────────────────────────────────────────────── + + fn files_recursive( + dir: &'static include_dir::Dir<'static>, + ) -> Vec<&'static include_dir::File<'static>> { + let mut out = Vec::new(); + let mut stack = vec![dir]; + while let Some(d) = stack.pop() { + out.extend(d.files()); + stack.extend(d.dirs()); + } + out + } + + #[test] + fn editor_lookup_serves_editor_index_with_react_mount() { + let file = lookup_embedded(PreviewUi::Editor, "index.html") + .expect("the editor embed always has an index.html (real dist or placeholder)"); + let expected = EMBEDDED_EDITOR + .get_file("index.html") + .expect("editor embed index.html") + .contents(); + assert_eq!( + file.contents(), + expected, + "editor mode must serve the *editor* embed's index, never the viewer's" + ); + let html = std::str::from_utf8(file.contents()).expect("index.html is UTF-8"); + assert!( + html.contains(r#"id="root""#), + "even the placeholder must carry the React mount point:\n{html}" + ); + } + + #[test] + fn viewer_lookup_never_reads_the_editor_embed() { + // Viewer mode is Phase-A behavior, byte for byte: any file that + // exists only in the editor embed must miss in viewer mode. + for editor_file in files_recursive(&EMBEDDED_EDITOR) { + let rel = editor_file.path().to_str().expect("utf-8 rel path"); + if EMBEDDED_SPA.get_file(rel).is_some() { + continue; // viewer legitimately has its own copy + } + assert!( + lookup_embedded(PreviewUi::Viewer, rel).is_none(), + "viewer mode must not serve editor-only asset {rel}" + ); + } + } + + #[test] + fn editor_lookup_falls_back_to_viewer_embed_for_shared_assets() { + // The dedupe routing: every viewer file the editor embed does + // not shadow must resolve through the editor lookup with the + // viewer's bytes. On a placeholder tree the shared set is empty + // and this loop body never runs; on a built tree the ~38 MB + // wasm_quarto_hub_client_bg-*.wasm is the load-bearing case. + for viewer_file in files_recursive(&EMBEDDED_SPA) { + let rel = viewer_file.path().to_str().expect("utf-8 rel path"); + if EMBEDDED_EDITOR.get_file(rel).is_some() { + continue; // editor's own copy wins; covered elsewhere + } + let served = lookup_embedded(PreviewUi::Editor, rel) + .unwrap_or_else(|| panic!("shared asset {rel} must fall back to the viewer embed")); + assert_eq!( + served.contents(), + viewer_file.contents(), + "fallback for {rel} must serve the viewer's bytes" + ); + } + } + + #[test] + fn editor_embed_holds_no_byte_identical_duplicates_of_viewer_files() { + // The build.rs strip contract behind the dedupe: anything + // byte-identical at the same rel path in both dists must have + // been stripped from the editor embed (it is served via the + // viewer fallback instead). Guards against a naive double-embed + // quietly re-adding ~38 MB to the binary. + for editor_file in files_recursive(&EMBEDDED_EDITOR) { + let rel = editor_file.path(); + if let Some(viewer_file) = EMBEDDED_SPA.get_file(rel) { + assert_ne!( + viewer_file.contents(), + editor_file.contents(), + "{} is byte-identical in both embeds — build.rs should have stripped it \ + from the editor embed", + rel.display() + ); + } + } + } +} diff --git a/crates/quarto-preview/tests/integration/boot.rs b/crates/quarto-preview/tests/integration/boot.rs index a245e5ab3..40445ece9 100644 --- a/crates/quarto-preview/tests/integration/boot.rs +++ b/crates/quarto-preview/tests/integration/boot.rs @@ -81,6 +81,7 @@ async fn boots_serves_spa_plus_hub_health() { cache_dir: None, allow_edit: false, share: false, + ui: Default::default(), }; // Spawn the server. `run()` blocks until shutdown; we abort the diff --git a/crates/quarto-preview/tests/integration/config_endpoint.rs b/crates/quarto-preview/tests/integration/config_endpoint.rs index 0d36e9148..4134bf4f1 100644 --- a/crates/quarto-preview/tests/integration/config_endpoint.rs +++ b/crates/quarto-preview/tests/integration/config_endpoint.rs @@ -59,6 +59,7 @@ async fn boot_server_for_test( cache_dir: None, allow_edit, share: false, + ui: Default::default(), }; let (ready_tx, ready_rx) = oneshot::channel::>(); diff --git a/crates/quarto-preview/tests/integration/diagnostics_capture_failure.rs b/crates/quarto-preview/tests/integration/diagnostics_capture_failure.rs index 7a935db97..9f9ca0cb2 100644 --- a/crates/quarto-preview/tests/integration/diagnostics_capture_failure.rs +++ b/crates/quarto-preview/tests/integration/diagnostics_capture_failure.rs @@ -80,6 +80,7 @@ async fn capture_failure_lands_in_sink() { cache_dir: None, allow_edit: false, share: false, + ui: Default::default(), }; let (ready_tx, ready_rx) = oneshot::channel::>(); diff --git a/crates/quarto-preview/tests/integration/diagnostics_endpoint.rs b/crates/quarto-preview/tests/integration/diagnostics_endpoint.rs index 4b7b78597..d2b4ce9f0 100644 --- a/crates/quarto-preview/tests/integration/diagnostics_endpoint.rs +++ b/crates/quarto-preview/tests/integration/diagnostics_endpoint.rs @@ -62,6 +62,7 @@ async fn boot_server_for_test() -> ( cache_dir: None, allow_edit: false, share: false, + ui: Default::default(), }; let (ready_tx, ready_rx) = oneshot::channel::>(); diff --git a/crates/quarto-preview/tests/integration/eager_capture.rs b/crates/quarto-preview/tests/integration/eager_capture.rs index 44b89eb9a..8bf6cbb9c 100644 --- a/crates/quarto-preview/tests/integration/eager_capture.rs +++ b/crates/quarto-preview/tests/integration/eager_capture.rs @@ -83,6 +83,7 @@ async fn eager_capture_populates_index_sidecar() { cache_dir: None, allow_edit: false, share: false, + ui: Default::default(), }; let (ready_tx, ready_rx) = oneshot::channel::>(); @@ -199,6 +200,7 @@ async fn prose_only_doc_leaves_sidecar_empty() { cache_dir: None, allow_edit: false, share: false, + ui: Default::default(), }; let (ready_tx, ready_rx) = oneshot::channel::>(); diff --git a/crates/quarto-preview/tests/integration/editor_ui.rs b/crates/quarto-preview/tests/integration/editor_ui.rs new file mode 100644 index 000000000..8a77689ef --- /dev/null +++ b/crates/quarto-preview/tests/integration/editor_ui.rs @@ -0,0 +1,125 @@ +//! `--ui editor` server-level test (live-share plan Phase 4, +//! bd-jt1etjbn): booting the real preview server with +//! `PreviewUi::Editor` must serve the *editor* embed at `/` (the full +//! hub-client build, or its placeholder on an unbuilt tree) while the +//! hub's own routes keep answering. This pins the config → OnceLock → +//! handler plumbing that the lib-level `lookup_embedded` unit tests +//! cannot see. +//! +//! The browser-tier verification (editor boots, sidebar, Monaco edit +//! write-back) is the phase's mandatory recorded e2e run, not a cargo +//! test. + +use std::net::TcpListener as StdTcpListener; +use std::time::{Duration, Instant}; + +use quarto_preview::{PreviewConfig, PreviewUi}; + +/// Bind `127.0.0.1:0`, capture the assigned port, release the listener. +fn pick_free_port() -> u16 { + let listener = StdTcpListener::bind("127.0.0.1:0").expect("probe bind"); + let port = listener.local_addr().expect("local_addr").port(); + drop(listener); + port +} + +/// Poll `GET /health` until 200 or a 10 s deadline. +async fn wait_for_health(port: u16) { + let url = format!("http://127.0.0.1:{port}/health"); + let deadline = Instant::now() + Duration::from_secs(10); + let client = reqwest::Client::new(); + loop { + if let Ok(resp) = client.get(&url).send().await + && resp.status().is_success() + { + return; + } + if Instant::now() >= deadline { + panic!("server didn't come up on port {port} within 10s"); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn editor_ui_serves_editor_index_and_keeps_hub_routes() { + let project = tempfile::TempDir::with_prefix("q2-preview-editor-proj-").unwrap(); + std::fs::write( + project.path().join("foo.qmd"), + "# Hello\n\nEditor-mode fixture page.\n", + ) + .unwrap(); + let data = tempfile::TempDir::with_prefix("q2-preview-editor-data-").unwrap(); + + let port = pick_free_port(); + let config = PreviewConfig { + host: "127.0.0.1".to_string(), + port, + project_root: Some(project.path().to_path_buf()), + single_file: None, + data_dir: data.path().to_path_buf(), + spa_dir_override: None, + engine_registry: None, + engine_policy: Default::default(), + resource_html_files: Vec::new(), + cache_dir: None, + allow_edit: false, + share: false, + ui: PreviewUi::Editor, + }; + + let server = tokio::spawn(async move { + let _ = quarto_preview::run(config).await; + }); + + wait_for_health(port).await; + + let expected_index = quarto_preview::embedded_editor_index_html() + .expect("the editor embed always has an index.html (real dist or placeholder)"); + + // 1. GET / serves the *editor* embed's index.html. + let body = reqwest::get(format!("http://127.0.0.1:{port}/")) + .await + .expect("GET /") + .error_for_status() + .expect("200 OK") + .text() + .await + .expect("body"); + assert_eq!( + body, expected_index, + "`--ui editor` must serve the editor embed's index.html at /" + ); + + // 2. Unknown paths still fall back to the (editor) index for + // client-side routing. + let fallback = reqwest::get(format!("http://127.0.0.1:{port}/no/such/path")) + .await + .expect("GET /no/such/path") + .error_for_status() + .expect("200 OK") + .text() + .await + .expect("body"); + assert_eq!( + fallback, expected_index, + "SPA fallback in editor mode must serve the editor index" + ); + + // 3. Hub routes keep winning over the SPA fallback: /health already + // answered above; the preview-config route must too (the editor + // reads nothing from it today, but the composition shape is the + // same one the viewer relies on). + let cfg: serde_json::Value = + reqwest::get(format!("http://127.0.0.1:{port}/api/preview/config")) + .await + .expect("GET /api/preview/config") + .error_for_status() + .expect("200 OK") + .json() + .await + .expect("json body"); + assert_eq!(cfg["allowEdit"], serde_json::Value::Bool(false)); + + server.abort(); +} diff --git a/crates/quarto-preview/tests/integration/join_tunnel.rs b/crates/quarto-preview/tests/integration/join_tunnel.rs index f91a42a51..ff0fe6710 100644 --- a/crates/quarto-preview/tests/integration/join_tunnel.rs +++ b/crates/quarto-preview/tests/integration/join_tunnel.rs @@ -72,6 +72,7 @@ async fn guest_syncs_project_through_tunnel() { cache_dir: None, allow_edit: false, share: false, + ui: Default::default(), }; // The real hub, exactly as `q2 preview` runs it. `run()` blocks until diff --git a/crates/quarto-preview/tests/integration/main.rs b/crates/quarto-preview/tests/integration/main.rs index 42b40b4fc..b68e1a3d9 100644 --- a/crates/quarto-preview/tests/integration/main.rs +++ b/crates/quarto-preview/tests/integration/main.rs @@ -7,6 +7,7 @@ pub mod config_endpoint; pub mod diagnostics_capture_failure; pub mod diagnostics_endpoint; pub mod eager_capture; +pub mod editor_ui; pub mod join_tunnel; pub mod render_scripts_boot; pub mod share; diff --git a/crates/quarto-preview/tests/integration/render_scripts_boot.rs b/crates/quarto-preview/tests/integration/render_scripts_boot.rs index 08e306c91..e87843f6c 100644 --- a/crates/quarto-preview/tests/integration/render_scripts_boot.rs +++ b/crates/quarto-preview/tests/integration/render_scripts_boot.rs @@ -118,6 +118,7 @@ async fn pre_render_scripts_run_once_at_boot() { cache_dir: None, allow_edit: false, share: false, + ui: Default::default(), }; let server = tokio::spawn(async move { diff --git a/crates/quarto-preview/tests/integration/staleness.rs b/crates/quarto-preview/tests/integration/staleness.rs index ca1ef45d8..0d6ec0c67 100644 --- a/crates/quarto-preview/tests/integration/staleness.rs +++ b/crates/quarto-preview/tests/integration/staleness.rs @@ -113,6 +113,7 @@ async fn cell_edit_flips_staleness_in_sidecar() { cache_dir: None, allow_edit: false, share: false, + ui: Default::default(), }; // ── Boot the server, capture the HubContext via on_ready ─────── diff --git a/crates/quarto/src/commands/preview.rs b/crates/quarto/src/commands/preview.rs index ee9bf386f..80b8f0a14 100644 --- a/crates/quarto/src/commands/preview.rs +++ b/crates/quarto/src/commands/preview.rs @@ -50,6 +50,10 @@ pub struct PreviewArgs { /// tunnel (bd-jhvkwosw). The server prints a `q2preview…` join /// string; the HTTP port itself stays loopback-bound. pub share: bool, + /// Which embedded frontend the server serves (`--ui`, live-share + /// plan Phase 4, bd-jt1etjbn): the read-only preview SPA (default) + /// or the full hub-client editor. Never changes the write policy. + pub ui: quarto_preview::PreviewUi, } pub fn execute(args: PreviewArgs) -> Result<()> { @@ -156,50 +160,69 @@ async fn run(args: PreviewArgs) -> Result<()> { // Phase D.2: encode the initial page (if any) as `?page=` // so the SPA's `pickInitialPage` helper can seed `activeFile`. - let url = build_boot_url(&host, port, initial_page.as_deref()); - info!(%url, "starting q2 preview server"); - println!(); - println!(" q2 preview"); - println!(" → {url}"); - println!(); - - // Phase D.1 (bd-kw93.8): actually open a browser tab. Failure to - // open is logged + non-fatal (the URL is already printed for - // copy-paste). Suppressed by --no-browser. // - // bd-a6dvrdg1: open only once the server is actually accepting - // connections. The server doesn't start until `quarto_preview::run` - // below (which then blocks until shutdown), so the open has to run - // on a spawned task that waits for readiness while the main task - // goes on to start the server. Opening eagerly here — as we used to - // — raced the server's startup: on larger projects the browser - // connected before `axum::serve` was live and showed "Unable to - // connect" until a manual reload. The probe (`wait_until_accepting`) - // closes that race by gating on the real accept condition. The port - // is the one we pre-probed; nothing is listening on it until the - // server binds, so the probe naturally retries across the gap. - if !args.no_browser { - let url_for_open = url.clone(); - let host_for_open = host.clone(); - tokio::spawn(async move { - const READY_TIMEOUT: Duration = Duration::from_secs(10); - if wait_until_accepting(&host_for_open, port, READY_TIMEOUT).await { - info!(host = %host_for_open, port, "preview server accepting connections; opening browser"); - } else { - // We still consider a >10s startup a bug; opening anyway - // (rather than never) preserves the old behavior as a - // floor, and the warning gives the slow start visibility - // instead of leaving it silent. - tracing::warn!( - host = %host_for_open, - port, - timeout_secs = READY_TIMEOUT.as_secs(), - "preview server has not accepted a connection within the timeout; \ - opening the browser anyway (it may need a manual reload)" - ); + // Phase 4 (bd-jt1etjbn): only viewer mode can print its boot URL + // here. The editor boot URL is the hub-client share route, which + // needs the index document id — that only exists once the hub is + // up, so editor mode defers the print + browser-open into the + // server's `on_ready` callback below (which fires before the + // listener binds, the same print-before-accept contract as here). + match args.ui { + quarto_preview::PreviewUi::Viewer => { + let url = build_boot_url(&host, port, initial_page.as_deref()); + info!(%url, "starting q2 preview server"); + println!(); + println!(" q2 preview"); + println!(" → {url}"); + println!(); + + // Phase D.1 (bd-kw93.8): actually open a browser tab. Failure to + // open is logged + non-fatal (the URL is already printed for + // copy-paste). Suppressed by --no-browser. + // + // bd-a6dvrdg1: open only once the server is actually accepting + // connections. The server doesn't start until `quarto_preview::run` + // below (which then blocks until shutdown), so the open has to run + // on a spawned task that waits for readiness while the main task + // goes on to start the server. Opening eagerly here — as we used to + // — raced the server's startup: on larger projects the browser + // connected before `axum::serve` was live and showed "Unable to + // connect" until a manual reload. The probe (`wait_until_accepting`) + // closes that race by gating on the real accept condition. The port + // is the one we pre-probed; nothing is listening on it until the + // server binds, so the probe naturally retries across the gap. + if !args.no_browser { + let url_for_open = url.clone(); + let host_for_open = host.clone(); + tokio::spawn(async move { + const READY_TIMEOUT: Duration = Duration::from_secs(10); + if wait_until_accepting(&host_for_open, port, READY_TIMEOUT).await { + info!(host = %host_for_open, port, "preview server accepting connections; opening browser"); + } else { + // We still consider a >10s startup a bug; opening anyway + // (rather than never) preserves the old behavior as a + // floor, and the warning gives the slow start visibility + // instead of leaving it silent. + tracing::warn!( + host = %host_for_open, + port, + timeout_secs = READY_TIMEOUT.as_secs(), + "preview server has not accepted a connection within the timeout; \ + opening the browser anyway (it may need a manual reload)" + ); + } + open_browser_or_log(&url_for_open, false); + }); } - open_browser_or_log(&url_for_open, false); - }); + } + quarto_preview::PreviewUi::Editor => { + info!("starting q2 preview server (editor UI)"); + println!(); + println!(" q2 preview — editor UI"); + if let Some(note) = quarto_preview::editor_ephemeral_note(args.allow_edit) { + println!(" {note}"); + } + } } // Phase C.6: read `preview.engine` from `_quarto.yml` so the @@ -244,8 +267,71 @@ async fn run(args: PreviewArgs) -> Result<()> { cache_dir: None, allow_edit: args.allow_edit, share: args.share, + ui: args.ui, }; - quarto_preview::run(config).await + + match args.ui { + quarto_preview::PreviewUi::Viewer => quarto_preview::run(config).await, + quarto_preview::PreviewUi::Editor => { + // Phase 4 (bd-jt1etjbn): the share-route boot URL needs the + // index doc id, which exists only server-side. `on_ready` + // channels it back: build the URL there, print it, and gate + // the browser-open on the same accept probe viewer mode uses. + let host_for_ready = config.host.clone(); + let project_name = config + .project_root + .as_deref() + .and_then(|r| r.file_name()) + .map_or_else( + || "q2 preview".to_string(), + |n| n.to_string_lossy().into_owned(), + ); + let no_browser = args.no_browser; + quarto_preview::run_with_on_ready(config, move |ctx| { + let paths: Vec = ctx.index().get_all_files().into_keys().collect(); + let url = match pick_editor_file(initial_page.as_deref(), &paths) { + Some(file) => build_editor_boot_url( + &host_for_ready, + port, + &ctx.index().document_id(), + &file, + &project_name, + ), + None => { + // No `.qmd` to seed the share route with (e.g. + // --no-project): boot to the editor's project + // selector instead of a broken share link. + tracing::warn!( + "no .qmd file found to open in the editor; \ + booting to the project selector" + ); + format!("http://{host_for_ready}:{port}/") + } + }; + info!(%url, "editor UI ready"); + println!(" → {url}"); + println!(); + if !no_browser { + let host_for_open = host_for_ready.clone(); + tokio::spawn(async move { + const READY_TIMEOUT: Duration = Duration::from_secs(10); + if !wait_until_accepting(&host_for_open, port, READY_TIMEOUT).await { + tracing::warn!( + host = %host_for_open, + port, + timeout_secs = READY_TIMEOUT.as_secs(), + "preview server has not accepted a connection within the \ + timeout; opening the browser anyway (it may need a manual \ + reload)" + ); + } + open_browser_or_log(&url, false); + }); + } + }) + .await + } + } } /// Guest path (live-share plan Phase 3, bd-6y0p1bne): parse the join @@ -668,9 +754,21 @@ pub(crate) fn build_boot_url(host: &str, port: u16, initial_page: Option<&str>) /// with literal slashes); percent-encodes everything else. Avoids /// pulling in a full URL crate just for one helper. fn percent_encode_path(s: &str) -> String { + percent_encode(s, true) +} + +/// Strict variant of [`percent_encode_path`] for query-param *values*: +/// also encodes `/`, matching what `URLSearchParams.toString()` emits +/// on the hub-client side (its parser is what decodes these). +fn percent_encode_component(s: &str) -> String { + percent_encode(s, false) +} + +fn percent_encode(s: &str, keep_slash: bool) -> String { let mut out = String::with_capacity(s.len()); for b in s.bytes() { - if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~' | b'/') { + let unreserved = b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~'); + if unreserved || (keep_slash && b == b'/') { out.push(b as char); } else { use std::fmt::Write; @@ -680,6 +778,52 @@ fn percent_encode_path(s: &str) -> String { out } +/// Phase 4 (bd-jt1etjbn): build the `--ui editor` boot URL — the +/// hub-client share route. The three params ride the *hash fragment* +/// (the SPA router parses `location.hash`, not the URL query; +/// `hub-client/src/utils/routing.ts`), all three are required by the +/// client's validation, and `server=%2Fws` is the relative sync +/// endpoint hub-client resolves against the page origin — which is +/// this preview server. The doc id travels bare: the client re-adds +/// the `automerge:` prefix, and `buildShareableUrl` on the TS side +/// strips it symmetrically. +pub(crate) fn build_editor_boot_url( + host: &str, + port: u16, + index_doc_id: &str, + file: &str, + project_name: &str, +) -> String { + let doc_id = index_doc_id + .strip_prefix("automerge:") + .unwrap_or(index_doc_id); + format!( + "http://{host}:{port}/#/share/{}?server=%2Fws&file={}&name={}", + percent_encode_component(doc_id), + percent_encode_component(file), + percent_encode_component(project_name), + ) +} + +/// Choose the share route's `file` param: the CLI-resolved initial +/// page when there is one, else the lexicographically first `.qmd` +/// known to the index (its files map is unordered; taking the minimum +/// keeps the boot deterministic). `None` when the project has no +/// `.qmd` at all. +pub(crate) fn pick_editor_file( + initial_page: Option<&str>, + index_paths: &[String], +) -> Option { + if let Some(page) = initial_page { + return Some(page.to_string()); + } + index_paths + .iter() + .filter(|p| p.ends_with(".qmd")) + .min() + .cloned() +} + #[cfg(test)] mod tests { use super::*; @@ -901,6 +1045,74 @@ mod tests { assert_eq!(percent_encode_path("a&b=c"), "a%26b%3Dc"); } + // ────────────────────────────────────────────────────────────── + // Phase 4 (bd-jt1etjbn): editor boot URL — the hub-client share + // route. Params ride the *hash fragment* (the SPA router parses + // location.hash, hub-client/src/utils/routing.ts), the doc id is + // bare (the client re-adds the `automerge:` prefix), and `server` + // is the relative `/ws` the client resolves against the page + // origin — which the preview server itself serves. + // ────────────────────────────────────────────────────────────── + + #[test] + fn build_editor_boot_url_emits_share_route_in_hash() { + assert_eq!( + build_editor_boot_url( + "127.0.0.1", + 8080, + "4XyZabc123", + "posts/intro.qmd", + "My Project" + ), + "http://127.0.0.1:8080/#/share/4XyZabc123?server=%2Fws&file=posts%2Fintro.qmd&name=My%20Project", + ); + } + + #[test] + fn build_editor_boot_url_strips_automerge_prefix() { + // `ctx.index().document_id()` is already bare, but the share + // route must never carry the prefix even if a caller passes it + // (mirrors routing.ts's buildShareableUrl, which strips it). + let url = build_editor_boot_url("127.0.0.1", 8080, "automerge:4XyZ", "a.qmd", "p"); + assert!( + url.contains("#/share/4XyZ?"), + "the route wants the bare doc id; got {url}" + ); + assert!( + !url.contains("automerge"), + "the automerge: prefix must not leak into the URL; got {url}" + ); + } + + #[test] + fn pick_editor_file_prefers_initial_page() { + let files = vec!["about.qmd".to_string(), "index.qmd".to_string()]; + assert_eq!( + pick_editor_file(Some("posts/intro.qmd"), &files).as_deref(), + Some("posts/intro.qmd"), + "an initial page resolved by the CLI wins over the index scan" + ); + } + + #[test] + fn pick_editor_file_falls_back_to_first_qmd_sorted() { + // The index files map is unordered (HashMap); the fallback must + // sort so the chosen page is deterministic across boots. + let files = vec![ + "zeta.qmd".to_string(), + "styles.css".to_string(), + "about.qmd".to_string(), + ]; + assert_eq!(pick_editor_file(None, &files).as_deref(), Some("about.qmd")); + } + + #[test] + fn pick_editor_file_none_without_any_qmd() { + let files = vec!["styles.css".to_string()]; + assert_eq!(pick_editor_file(None, &files), None); + assert_eq!(pick_editor_file(None, &[]), None); + } + // ────────────────────────────────────────────────────────────── // bd-a6dvrdg1: wait_until_accepting — gate the browser-open on the // preview server actually accepting connections (not on the diff --git a/crates/quarto/src/main.rs b/crates/quarto/src/main.rs index a3a0cea39..e559fd9eb 100644 --- a/crates/quarto/src/main.rs +++ b/crates/quarto/src/main.rs @@ -249,19 +249,34 @@ enum Commands { #[arg(long)] share: bool, + /// Which frontend to serve: the read-only preview UI + /// (`viewer`, the default) or the full collaborative editor + /// (`editor`: Monaco, file sidebar, live preview pane). + /// + /// Choosing `editor` never changes the write policy: without + /// --allow-edit, edits made in the editor drive the live + /// session for everyone connected but are never written to + /// your files (and a file change on disk converges the + /// session back to the disk content). Two caveats: the editor + /// stores a project entry and a local document cache in the + /// browser for every preview session, and with --share the + /// host's --ui choice is what all guests get. + #[arg(long, value_enum, default_value_t = PreviewUiArg::Viewer)] + ui: PreviewUiArg, + /// Join a shared preview session using the `q2preview…` string /// printed by `q2 preview --share` on the host machine. /// /// Runs a local proxy for the host's session — no local project /// is read and nothing is written to disk on this machine, so /// the host-mode flags (a path, --share, --no-project, - /// --allow-edit, --data-dir, --preview-dir) don't combine with - /// it. --port/--host pick where the local proxy listens; + /// --allow-edit, --ui, --data-dir, --preview-dir) don't combine + /// with it. --port/--host pick where the local proxy listens; /// --no-browser still applies. #[arg( long, value_name = "TICKET", - conflicts_with_all = ["path", "share", "no_project", "allow_edit", "data_dir", "preview_dir"] + conflicts_with_all = ["path", "share", "no_project", "allow_edit", "data_dir", "preview_dir", "ui"] )] join: Option, }, @@ -747,6 +762,27 @@ enum TraceCommand { }, } +/// `--ui` values for `q2 preview` (live-share plan Phase 4, +/// bd-jt1etjbn): which embedded frontend the preview server serves. +/// CLI-side mirror of [`quarto_preview::PreviewUi`] so the library +/// doesn't grow a clap dependency. +#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)] +enum PreviewUiArg { + /// The read-only preview UI (the default). + Viewer, + /// The full hub-client editor. Does not imply --allow-edit. + Editor, +} + +impl From for quarto_preview::PreviewUi { + fn from(arg: PreviewUiArg) -> Self { + match arg { + PreviewUiArg::Viewer => quarto_preview::PreviewUi::Viewer, + PreviewUiArg::Editor => quarto_preview::PreviewUi::Editor, + } + } +} + #[cfg(test)] mod cli_parse_tests { //! clap parse harness (live-share plan, Phase 2). These are the first @@ -869,6 +905,73 @@ mod cli_parse_tests { assert_join_conflict(&["preview", "--join", "x", "--preview-dir", "d"]); } + // ── Phase 4 (bd-jt1etjbn): `--ui ` ──────────────── + // The flag substitutes which embedded frontend the server serves. + // It is orthogonal to `--allow-edit` (UI × write policy is a real + // 2×2) and meaningless for a guest (the host's server serves the + // UI through the tunnel), hence the `--join` conflict. + + use super::PreviewUiArg; + + #[test] + fn preview_ui_defaults_to_viewer() { + let Commands::Preview { ui, .. } = parse_preview(&["preview"]) else { + unreachable!() + }; + assert_eq!(ui, PreviewUiArg::Viewer, "--ui must default to viewer"); + } + + #[test] + fn preview_ui_parses_viewer_and_editor() { + for (value, expected) in [ + ("viewer", PreviewUiArg::Viewer), + ("editor", PreviewUiArg::Editor), + ] { + let Commands::Preview { ui, .. } = parse_preview(&["preview", "--ui", value]) else { + unreachable!() + }; + assert_eq!(ui, expected, "--ui {value}"); + } + } + + #[test] + fn preview_ui_rejects_unknown_value_listing_the_valid_ones() { + let err = match try_parse(&["preview", "--ui", "monaco"]) { + Ok(_) => panic!("--ui monaco is not a frontend we ship; must be rejected"), + Err(e) => e, + }; + assert_eq!(err.kind(), clap::error::ErrorKind::InvalidValue); + let msg = err.to_string(); + assert!( + msg.contains("viewer") && msg.contains("editor"), + "the error must list the valid values; got: {msg}" + ); + } + + #[test] + fn preview_ui_composes_with_share_and_allow_edit() { + // The full-collaboration composition from the plan's CLI surface: + // `--share --ui editor --allow-edit`. + let Commands::Preview { + share, + ui, + allow_edit, + .. + } = parse_preview(&["preview", "--share", "--ui", "editor", "--allow-edit"]) + else { + unreachable!() + }; + assert!(share && allow_edit); + assert_eq!(ui, PreviewUiArg::Editor); + } + + #[test] + fn preview_join_conflicts_with_ui() { + // The Phase 3 conflict-matrix entry deferred to Phase 4 (the + // flag didn't exist yet). + assert_join_conflict(&["preview", "--join", "x", "--ui", "editor"]); + } + #[test] fn preview_join_composes_with_guest_flags() { // `--port` picks the local proxy port, `--host` its bind @@ -962,6 +1065,7 @@ fn main() -> Result<()> { no_project, allow_edit, share, + ui, join, } => { if let Some(ticket) = join { @@ -984,6 +1088,7 @@ fn main() -> Result<()> { no_project, allow_edit, share, + ui: ui.into(), }) } } diff --git a/crates/xtask/src/build_hub_client_embed.rs b/crates/xtask/src/build_hub_client_embed.rs new file mode 100644 index 000000000..c65f8a730 --- /dev/null +++ b/crates/xtask/src/build_hub_client_embed.rs @@ -0,0 +1,58 @@ +//! `cargo xtask build-hub-client-embed` — build the hub-client editor +//! bundle that `q2 preview --ui editor` embeds. +//! +//! Runs hub-client's `build:preview-embed` npm script (live-share plan +//! Phase 4, bd-jt1etjbn): a hub-client production build with auth off +//! (no `VITE_GOOGLE_CLIENT_ID`), the sync server pinned to the relative +//! `/ws` the preview server itself serves, and the PWA service worker +//! disabled, emitted to `hub-client/dist-preview-embed/`. The +//! `quarto-preview` crate's `include_dir!` picks the directory up on +//! the next Rust compile (files byte-identical to the q2-preview-spa +//! viewer dist — notably the ~38 MB WASM — are stripped at embed time +//! and served through the viewer embed instead). +//! +//! Mirrors `build_q2_preview_spa.rs` for the viewer SPA. + +use anyhow::{Context, Result, bail}; +use std::path::PathBuf; + +use crate::util::nested_command; + +pub fn run() -> Result<()> { + let project_root = find_project_root()?; + let hub_client_dir = project_root.join("hub-client"); + if !hub_client_dir.join("package.json").is_file() { + bail!( + "hub-client/package.json not found under {}", + project_root.display() + ); + } + + println!("━━━ Building hub-client preview embed ━━━"); + let status = nested_command("npm") + .args(["run", "build:preview-embed"]) + .current_dir(&hub_client_dir) + .status() + .with_context(|| format!("Failed to spawn npm in {}", hub_client_dir.display()))?; + if !status.success() { + bail!("hub-client preview-embed build failed"); + } + println!("✓ hub-client/dist-preview-embed/ is up to date"); + Ok(()) +} + +fn find_project_root() -> Result { + let mut dir = std::env::current_dir().context("Failed to get current directory")?; + loop { + let cargo_toml = dir.join("Cargo.toml"); + if cargo_toml.exists() { + let content = std::fs::read_to_string(&cargo_toml)?; + if content.contains("[workspace]") { + return Ok(dir); + } + } + if !dir.pop() { + bail!("Could not find workspace root (Cargo.toml with [workspace])"); + } + } +} diff --git a/crates/xtask/src/main.rs b/crates/xtask/src/main.rs index b3ea134e6..7bfc34c33 100644 --- a/crates/xtask/src/main.rs +++ b/crates/xtask/src/main.rs @@ -15,11 +15,13 @@ //! - `verify`: Run full project verification (build + tests for Rust and hub-client) //! - `build-all`: Fresh-clone build orchestration (npm install + hub-client + Rust workspace) //! - `build-trace-viewer`: Build just the trace-viewer SPA +//! - `build-hub-client-embed`: Build the hub-client editor bundle for `q2 preview --ui editor` //! - `build-hub-mcp-bundle`: Build the self-contained hub MCP server bundle //! - `stage-doc-examples`: Render `examples/manifest.yml` projects into `docs/examples/` mod braid_snapshot; mod build_all; +mod build_hub_client_embed; mod build_hub_mcp_bundle; mod build_q2_preview_spa; mod build_trace_viewer; @@ -240,6 +242,14 @@ enum Command { /// build -p quarto-preview` (via `include_dir!`). BuildQ2PreviewSpa {}, + /// Build the hub-client editor bundle for `q2 preview --ui editor`. + /// + /// Produces `hub-client/dist-preview-embed/` (auth off, sync server + /// pinned to the relative `/ws`, PWA disabled) — picked up on the + /// next `cargo build -p quarto-preview` (via `include_dir!`, with + /// viewer-dist-identical files stripped at embed time). + BuildHubClientEmbed {}, + /// Build the self-contained hub MCP server bundle. /// /// Produces `ts-packages/quarto-hub-mcp/dist-bundle/` (esbuild) — @@ -366,6 +376,7 @@ fn main() -> Result<()> { Command::StageDocExamples {} => stage_doc_examples::run(), Command::BuildTraceViewer {} => build_trace_viewer::run(), Command::BuildQ2PreviewSpa {} => build_q2_preview_spa::run(), + Command::BuildHubClientEmbed {} => build_hub_client_embed::run(), Command::BuildHubMcpBundle {} => build_hub_mcp_bundle::run(), Command::BuildAll { skip_npm_install, diff --git a/hub-client/.gitignore b/hub-client/.gitignore index 664761f4e..030b33c59 100644 --- a/hub-client/.gitignore +++ b/hub-client/.gitignore @@ -10,6 +10,7 @@ lerna-debug.log* node_modules dist dist-ssr +dist-preview-embed *.local # Playwright diff --git a/hub-client/package.json b/hub-client/package.json index 5b57d58a2..25db2c38d 100644 --- a/hub-client/package.json +++ b/hub-client/package.json @@ -12,6 +12,7 @@ "build:sandboxed": "cd quarto-hub-sandboxed-preview && npm install --silent && npm run build", "build:all": "npm run build:wasm && npm run build:sandboxed && npm run build", "build:local-prod": "../scripts/build-local-prod.sh", + "build:preview-embed": "npm run build:wasm && npm run build:sandboxed && tsc -b && VITE_GOOGLE_CLIENT_ID= VITE_DISABLE_PWA=1 VITE_DEFAULT_SYNC_SERVER=/ws NODE_OPTIONS=--max-old-space-size=4096 vite build --outDir dist-preview-embed", "postinstall": "cd quarto-hub-sandboxed-preview && npm install --silent", "typecheck": "tsc -p tsconfig.app.json --noEmit", "lint": "eslint .", diff --git a/hub-client/vite.config.ts b/hub-client/vite.config.ts index 62bbe6865..794cdee6c 100644 --- a/hub-client/vite.config.ts +++ b/hub-client/vite.config.ts @@ -57,6 +57,14 @@ const hubTarget = process.env.VITE_HUB_SERVER || 'http://localhost:3000'; /** Disable service worker in E2E tests to avoid caching interference */ const isE2E = process.env.VITE_E2E === '1'; +/** + * Disable the PWA service worker entirely (`build:preview-embed`). + * The q2-preview embed serves this app from an ephemeral localhost + * origin per `q2 preview` session; a service worker would precache + * ~67 MB (WASM included) into Cache Storage for every random port. + */ +const disablePwa = process.env.VITE_DISABLE_PWA === '1'; + // https://vite.dev/config/ export default defineConfig({ base: './', @@ -95,7 +103,7 @@ export default defineConfig({ }, }, // Disable PWA service worker in E2E tests to avoid caching interference - ...(!isE2E ? [VitePWA({ + ...(!isE2E && !disablePwa ? [VitePWA({ registerType: 'autoUpdate', includeAssets: ['quarto-icon.svg'], manifest: { From bcfbb47f53d55c12164c32cfd10cef3aa7bf740f Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:28:05 +0100 Subject: [PATCH 09/22] feat(preview): skip project-set setup on ephemeral editor boots (bd-zf4ryvuq) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit q2 preview --ui editor boots hub-client via a share URL, but App.tsx gates rendering on project-set status: a fresh browser (the default — preview binds an ephemeral port, so every origin has fresh IndexedDB) landed on the ProjectSetSetup create/migrate page instead of the preview. Onboarding for a synced project list is pointless against a throwaway per-session hub. The preview server's boot URL now carries ephemeral=true on the share route (build_editor_boot_url). hub-client captures the flag once at mount (before the share handler clears the URL), then mirrors the join-collection invite-first pattern: silently establish the personal root set against /ws (createProjectSet on needs-setup, migrateProjects on needs-migration) and bypass the needs-setup/needs-migration/error ProjectSetSetup gates. Production share links never carry the param (buildShareableUrl/buildHashRoute unchanged), so the production onboarding flow is untouched. Verified: new Rust + vitest cases failed pre-implementation and pass after; cargo xtask verify — all 14 steps. E2E against the real binary (headless Chromium, fresh profile): editor mounts with index.qmd and ProjectSetSetup never renders; control run without the param shows the setup page as before. hub-client changelog entry waived for this change. --- ...08-07-preview-editor-skip-project-setup.md | 169 ++++++++++++++++++ crates/quarto/src/commands/preview.rs | 23 ++- hub-client/src/App.tsx | 50 +++++- hub-client/src/utils/routing.test.ts | 32 ++++ hub-client/src/utils/routing.ts | 11 ++ 5 files changed, 277 insertions(+), 8 deletions(-) create mode 100644 claude-notes/plans/2026-08-07-preview-editor-skip-project-setup.md diff --git a/claude-notes/plans/2026-08-07-preview-editor-skip-project-setup.md b/claude-notes/plans/2026-08-07-preview-editor-skip-project-setup.md new file mode 100644 index 000000000..bf23576e8 --- /dev/null +++ b/claude-notes/plans/2026-08-07-preview-editor-skip-project-setup.md @@ -0,0 +1,169 @@ +# Skip project-set setup for `q2 preview --ui editor` (ephemeral hub) + +Strand: bd-zf4ryvuq + +## Overview + +`q2 preview --ui editor` boots the browser to a hub-client share URL +(`#/share/?server=%2Fws&file=…&name=…`, built by +`build_editor_boot_url` in +[preview.rs](../../crates/quarto/src/commands/preview.rs)). Before the +share handler can land the user in the editor, `App.tsx` gates rendering +on `projectSetState.status`: a fresh browser profile (`needs-setup`) or +one with legacy IDB projects (`needs-migration`) gets the +`ProjectSetSetup` create/migrate page instead of the preview. Preview +binds an ephemeral port by default, so IndexedDB is almost always fresh +— every preview boot shows onboarding for a project set the user does +not need (the hub is a throwaway per-session server; see the sibling +plan +[2026-08-07-ephemeral-hub-secrets-for-preview.md](2026-08-07-ephemeral-hub-secrets-for-preview.md)). + +**Approach:** the preview server's boot URL carries `ephemeral=true` on +the share route. hub-client captures the flag once at mount (before the +share handler clears the URL), then: + +1. **Silently establishes the personal root set**, mirroring the + existing invite-first onboarding effect for `join-collection` + (`App.tsx`): `needs-setup` → `createProjectSet(DEFAULT_SYNC_SERVER)`; + `needs-migration` → `migrateProjects(DEFAULT_SYNC_SERVER)`. In the + preview-embed build `DEFAULT_SYNC_SERVER` is `/ws` (hub-client + `package.json` `build:preview-embed`) — the ephemeral hub itself. + Fire-once ref guard, same retry-loop rationale as join-collection. +2. **Bypasses the `ProjectSetSetup` gates** (`needs-setup` / + `needs-migration` / `error`) when the flag is set, so the user goes + straight to the connecting state and then the editor. + +Production is unaffected: `buildShareableUrl` / `buildHashRoute` never +emit the param, so only preview-generated boot URLs carry it. + +## Work Items + +### Phase 1 — Tests first (TDD) + +- [x] `crates/quarto/src/commands/preview.rs`: update + `build_editor_boot_url_emits_share_route_in_hash` and + `build_editor_boot_url_strips_automerge_prefix` to expect + `&ephemeral=true`; add a test asserting the flag is present and is the + last param. Confirm fail. (Confirmed: both failed pre-implementation.) +- [x] `hub-client/src/utils/routing.test.ts`: share-route parse tests — + `&ephemeral=true` → `ephemeral: true` on the route; absent param → + field absent (conditional spread, mirrors `anchor`); non-`true` value + → absent. Confirm fail. (Confirmed: parse test failed + pre-implementation.) + +### Phase 2 — Rust implementation + +- [x] `build_editor_boot_url`: append `&ephemeral=true`; extend the doc + comment (the param marks the serving hub as ephemeral; client skips + project-set onboarding). +- [x] `cargo nextest run -p quarto build_editor_boot_url` — 3 passed. + +### Phase 3 — hub-client implementation + +- [x] `routing.ts`: `ShareRoute` gains optional `ephemeral?: boolean` + (doc comment: only preview boot URLs set it; never emitted by + `buildHashRoute`); parse via conditional spread, mirroring the + `anchor` pattern. `routesEqual` unchanged (flag is not a location + discriminator). +- [x] `App.tsx`: + - Capture once at mount: `useState(() => { const r = + parseHashRoute(window.location.hash); return r.type === 'share' && + r.ephemeral === true; })` (mirrors the `authErrorReason` pattern). + - Silent-establish effect after the join-collection one, with a + fire-once ref (`ephemeralRootInitiatedRef`). + - Gate skips: `!ephemeralHub && …` on the needs-setup/needs-migration + gate and the error gate. +- [x] `npx vitest run src/utils/routing.test.ts` (85 passed); `tsc -b` + clean. + +### Phase 4 — End-to-end verification + +- [x] Rebuild the embedded editor: `cargo xtask build-hub-client-embed` + (runs hub-client `build:preview-embed`), then `cargo build --bin q2`. +- [x] Boot `target/debug/q2 preview examples/websites/01-minimal + --ui editor --no-browser`, confirm the printed URL carries + `ephemeral=true`. Observed: + + ``` + → http://127.0.0.1:53411/#/share/qe5rdRWvXH5eVGY5azrZ5bMZQtE?server=%2Fws&file=index.qmd&name=01-minimal&ephemeral=true + ``` + +- [x] Drive a real browser (Playwright script, fresh profile) against + the printed URL: assert the editor loads the file and the + ProjectSetSetup page ("Create New Project Set" / migration) never + appears. Evidence (script drove headless Chromium with a fresh + profile — no IDB/localStorage): + + ``` + PASS: .editor-container became visible + PASS: ProjectSetSetup never rendered + PASS: editor shows index.qmd + final url: http://127.0.0.1:53411/#/p//file/index.qmd + ``` + + Control run against the same server with the param removed (the + "before" behavior): `ProjectSetSetup visible: true`, editor not + visible. Two console errors appear in BOTH runs and are pre-existing, + unrelated to this change: `401 /auth/me` (expected in auth-less + builds — projectSetService.ts documents it always 401s without + VITE_GOOGLE_CLIENT_ID) and `WASM module not initialized` from + `disconnect()`'s `vfsClear` (automergeSync.ts:169) firing on the + share handler's URL-clearing route change before `connect()`'s + `await initWasm()` — present without the ephemeral flag too. +- [ ] Permanent Playwright spec spawning the q2 binary: deferred — the + e2e suite's globalSetup boots a hub on :3031 for all specs and + nothing in the suite builds/runs the q2 binary (the embed is baked at + cargo-build time), so a preview-boot spec needs its own harness. + Filed as follow-up on bd-zf4ryvuq. + +### Phase 5 — Full verification + +- [x] `cargo xtask verify` (full — hub-client changed): all 14 steps + passed. This covers `cargo build --workspace`, `cargo nextest run + --workspace`, the ts-packages builds, `cd hub-client && npm run + build:all` (the CLAUDE.md-required strict hub-client build), and + hub-client `test:ci`. The embedded editor bundle was rebuilt + separately via `cargo xtask build-hub-client-embed` (Phase 4) and + `cargo build --bin q2` re-embedded it. + +### Phase 6 — Bookkeeping + +- [x] Close bd-zf4ryvuq. + +(The hub-client changelog entry is waived for this change — user +decision, 2026-08-07. The CLAUDE.md two-commit changelog workflow does +not apply here.) + +## Details + +### Design decisions + +1. **Signal rides the boot URL, not `/api/preview/config`.** The + endpoint would need a new boot-time fetch in hub-client (404 in + production) and races with the render gates; the hash param travels + with the one URL only the preview server generates, alongside the + existing `server=%2Fws` signal, and is captured before the share + handler's SECURITY URL-clearing. +2. **Silent auto-setup, not a bare gate skip.** A bare skip leaves + `status` stuck at `needs-setup`: ProjectsHome would flash during the + share connect and degrade if the user navigates home. Silently + establishing the root (the join-collection pattern) makes the whole + app coherent — add-to-set, reconciliation, and ProjectsHome all work + — for the cost of one empty root doc per fresh origin. Default + preview ports are ephemeral, so each boot is a fresh origin with no + accumulation. +3. **`ephemeral?: boolean` via conditional spread** (omitted when + absent), matching `FileRoute.anchor`; existing parse-test objects + stay valid, `buildShareableUrl` needs no change. +4. **Skip the `error` gate too** in ephemeral mode: a project-set + failure must not block the preview — the share connect proceeds + without the set. + +### Explicitly out of scope + +- `--no-project` editor boots (no share URL; lands on the project + selector, still gated). Would need a different signal channel. +- IDB accumulation of dead per-session project entries on pinned-port + preview workflows (pre-existing; the silent root set actually absorbs + them via migration). +- `routesEqual` ignores `ephemeral` (not a location discriminator). diff --git a/crates/quarto/src/commands/preview.rs b/crates/quarto/src/commands/preview.rs index 80b8f0a14..1be516db8 100644 --- a/crates/quarto/src/commands/preview.rs +++ b/crates/quarto/src/commands/preview.rs @@ -787,6 +787,13 @@ fn percent_encode(s: &str, keep_slash: bool) -> String { /// this preview server. The doc id travels bare: the client re-adds /// the `automerge:` prefix, and `buildShareableUrl` on the TS side /// strips it symmetrically. +/// +/// `ephemeral=true` (bd-zf4ryvuq) marks the serving hub as a throwaway +/// per-session preview server: the client captures the flag before the +/// share handler clears the URL, silently establishes a project-set +/// root against `/ws`, and skips the setup/migration gate so the user +/// lands straight in the preview. Only preview boot URLs carry it — +/// `buildShareableUrl` never emits it. pub(crate) fn build_editor_boot_url( host: &str, port: u16, @@ -798,7 +805,7 @@ pub(crate) fn build_editor_boot_url( .strip_prefix("automerge:") .unwrap_or(index_doc_id); format!( - "http://{host}:{port}/#/share/{}?server=%2Fws&file={}&name={}", + "http://{host}:{port}/#/share/{}?server=%2Fws&file={}&name={}&ephemeral=true", percent_encode_component(doc_id), percent_encode_component(file), percent_encode_component(project_name), @@ -1064,7 +1071,19 @@ mod tests { "posts/intro.qmd", "My Project" ), - "http://127.0.0.1:8080/#/share/4XyZabc123?server=%2Fws&file=posts%2Fintro.qmd&name=My%20Project", + "http://127.0.0.1:8080/#/share/4XyZabc123?server=%2Fws&file=posts%2Fintro.qmd&name=My%20Project&ephemeral=true", + ); + } + + #[test] + fn build_editor_boot_url_marks_hub_ephemeral() { + // The preview hub is a throwaway per-session server; the client + // reads `ephemeral=true` to skip project-set onboarding and go + // straight to the preview (bd-zf4ryvuq). + let url = build_editor_boot_url("127.0.0.1", 8080, "4XyZ", "a.qmd", "p"); + assert!( + url.ends_with("&ephemeral=true"), + "preview boot URLs must carry the ephemeral flag; got {url}" ); } diff --git a/hub-client/src/App.tsx b/hub-client/src/App.tsx index 2bf909e31..48f3257d5 100644 --- a/hub-client/src/App.tsx +++ b/hub-client/src/App.tsx @@ -44,7 +44,7 @@ import { useSessionKeepAlive } from './hooks/useSessionKeepAlive'; import { useExecutionChannel } from './hooks/useExecutionChannel'; import { resolveActorId as resolveActorIdRequest } from './services/authService'; import type { Route, ShareRoute, LinkProjectSetRoute } from './utils/routing'; -import { resolveSyncServerUrl, DEFAULT_SYNC_SERVER } from './utils/routing'; +import { resolveSyncServerUrl, DEFAULT_SYNC_SERVER, parseHashRoute } from './utils/routing'; import './App.css'; /** @@ -188,6 +188,16 @@ function App() { return reason; }); + // Capture the ephemeral-hub flag from the boot URL (once, before the + // share handler below clears the hash from the address bar). Only + // `q2 preview --ui editor` emits it: the serving hub is a throwaway + // per-session server, so project-set onboarding is skipped entirely + // (bd-zf4ryvuq). + const [ephemeralHub] = useState(() => { + const bootRoute = parseHashRoute(window.location.hash); + return bootRoute.type === 'share' && bootRoute.ephemeral === true; + }); + // Load screen name from IndexedDB (for identity mapping in Automerge docs). // When auth is enabled, wait for it to resolve so we can upgrade anonymous // names to the OIDC display name on first login. Without auth, load immediately. @@ -251,6 +261,29 @@ function App() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [route.type, projectSetState.status]); + // Ephemeral preview boot (bd-zf4ryvuq): same invite-first pattern as + // join-collection above. The user asked for a preview, not project + // management, so establish the personal root silently — create on a + // fresh browser, migrate when legacy IDB projects exist — and never + // show the setup/migration screens. DEFAULT_SYNC_SERVER is '/ws' in + // the preview-embed build, i.e. the ephemeral hub itself; the root + // doc lives in IndexedDB and re-syncs to whatever ephemeral hub serves + // this origin next. + const ephemeralRootInitiatedRef = useRef(false); + useEffect(() => { + if (!ephemeralHub || ephemeralRootInitiatedRef.current) return; + if (projectSetState.status === 'needs-setup') { + ephemeralRootInitiatedRef.current = true; + projectSetActions.createProjectSet(DEFAULT_SYNC_SERVER); + } else if (projectSetState.status === 'needs-migration') { + // Fire once: migrateProjects resets to needs-migration on failure, so + // an unguarded effect would retry-loop against an unreachable server. + ephemeralRootInitiatedRef.current = true; + projectSetActions.migrateProjects(DEFAULT_SYNC_SERVER); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ephemeralHub, projectSetState.status]); + // Denormalize a peek summary onto this user's project-set entry while a // project is open. Kept current as files and identities change (both are // low-frequency: file add/remove/rename, presence join). This is a per-user @@ -736,10 +769,13 @@ function App() { ); } - // Show project set setup/migration screen if needed + // Show project set setup/migration screen if needed. Ephemeral + // preview boots skip it: the effect above establishes the root + // silently while the share handler connects. if ( - projectSetState.status === 'needs-setup' || - projectSetState.status === 'needs-migration' + !ephemeralHub && + (projectSetState.status === 'needs-setup' || + projectSetState.status === 'needs-migration') ) { return ( { expect(parseHashRoute('#/share/')).toEqual({ type: 'project-selector' }); expect(parseHashRoute('#/share')).toEqual({ type: 'project-selector' }); }); + + // `q2 preview --ui editor` boot URLs mark the serving hub as an + // ephemeral per-session server so the app can skip project-set + // onboarding (bd-zf4ryvuq). + it('parses ephemeral=true on share routes', () => { + const result = parseHashRoute( + '#/share/4XyZabc123?server=%2Fws&file=index.qmd&name=Preview&ephemeral=true' + ); + expect(result).toEqual({ + type: 'share', + indexDocId: '4XyZabc123', + syncServer: '/ws', + filePath: 'index.qmd', + name: 'Preview', + ephemeral: true, + }); + }); + + it('omits ephemeral when the param is absent or not exactly "true"', () => { + const withoutParam = parseHashRoute('#/share/4XyZabc123?server=%2Fws&file=a.qmd&name=P'); + expect(withoutParam).not.toHaveProperty('ephemeral'); + + const falseValue = parseHashRoute( + '#/share/4XyZabc123?server=%2Fws&file=a.qmd&name=P&ephemeral=false' + ); + expect(falseValue).not.toHaveProperty('ephemeral'); + + const otherValue = parseHashRoute( + '#/share/4XyZabc123?server=%2Fws&file=a.qmd&name=P&ephemeral=1' + ); + expect(otherValue).not.toHaveProperty('ephemeral'); + }); }); describe('link-project-set routes', () => { diff --git a/hub-client/src/utils/routing.ts b/hub-client/src/utils/routing.ts index b25407a05..ccc1a391b 100644 --- a/hub-client/src/utils/routing.ts +++ b/hub-client/src/utils/routing.ts @@ -106,6 +106,14 @@ export interface ShareRoute { filePath: string; /** Human-readable project name */ name: string; + /** + * True only on boot URLs generated by `q2 preview --ui editor`: the + * serving hub is an ephemeral per-session server, so the app skips + * project-set onboarding and silently establishes a root set against + * it (bd-zf4ryvuq). Never emitted by buildHashRoute — production + * share links must not carry it. + */ + ephemeral?: boolean; } /** @@ -223,11 +231,13 @@ export function parseHashRoute(hash: string): Route { // Parse share route: /share/?server=&file=&name= // All three query parameters are required. Missing fields are set to empty // strings; App.tsx validates and shows an error for malformed share links. + // `ephemeral=true` is optional and marks a `q2 preview` boot URL. if (segments[0] === 'share' && segments[1]) { const indexDocId = decodeURIComponent(segments[1]); const server = queryParams.get('server') ?? ''; const fileParam = queryParams.get('file') ?? ''; const nameParam = queryParams.get('name') ?? ''; + const ephemeral = queryParams.get('ephemeral') === 'true'; return { type: 'share', @@ -235,6 +245,7 @@ export function parseHashRoute(hash: string): Route { syncServer: server, filePath: fileParam ? decodeURIComponent(fileParam) : '', name: nameParam ? decodeURIComponent(nameParam) : '', + ...(ephemeral && { ephemeral: true }), }; } From 7b3c019df300b8c4ed332dd34a69e8bd15cf227d Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:40:20 +0100 Subject: [PATCH 10/22] fix(preview): boot --join guests to the editor share route (bd-7htq16rx) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit q2 preview --join against a host running --ui editor --share opened the browser at the guest proxy's root route, which carries no document coordinates: a fresh profile hit the ProjectSetSetup gate, and even past it the app landed on ProjectsHome — the share handler that joins the document only runs for #/share/… URLs, so guests never joined. The host's editor-mode on_ready now stashes its boot params (index doc id, file, project name) via quarto_preview::set_editor_boot, and GET /api/preview/config carries them as editorBoot. The guest fetches the config through the tunnel after its /health readiness probe and boots the same share URL the host printed — ephemeral=true included, so the bd-zf4ryvuq machinery skips project-set onboarding — built from the same editor_share_route helper as the host's URL. Viewer-mode and older hosts answer without editorBoot and keep the root URL, so both skew directions degrade to today's behavior. The ticket can't carry the doc id (minted before the hub boots), which is why the params ride the config endpoint instead; the doc id was already exposed to guests via /health. Verified: TDD (compile-red confirmed) — new quarto-preview integration test plus 5 CLI unit tests; cargo nextest run --workspace 10939 passed; cargo xtask verify --skip-hub-build green. E2E with real host+guest binaries: guest prints the share URL with the host's doc id and ephemeral=true; fresh-profile headless Chromium at the guest URL loads the editor on index.qmd through the tunnel with ProjectSetSetup never rendering, while the root-URL control on the same session still shows the setup gate. --- ...2026-08-07-preview-join-editor-boot-url.md | 214 +++++++++++++++++ crates/quarto-preview/src/lib.rs | 49 +++- .../tests/integration/config_endpoint.rs | 84 +++++++ crates/quarto/src/commands/preview.rs | 227 +++++++++++++++--- 4 files changed, 544 insertions(+), 30 deletions(-) create mode 100644 claude-notes/plans/2026-08-07-preview-join-editor-boot-url.md diff --git a/claude-notes/plans/2026-08-07-preview-join-editor-boot-url.md b/claude-notes/plans/2026-08-07-preview-join-editor-boot-url.md new file mode 100644 index 000000000..6d43b9441 --- /dev/null +++ b/claude-notes/plans/2026-08-07-preview-join-editor-boot-url.md @@ -0,0 +1,214 @@ +# Editor boot URL for `q2 preview --join` guests (skip project-set setup) + +Strand: bd-7htq16rx + +## Overview + +`q2 preview --join ` against a host running `q2 preview --ui +editor --share` opens the browser at `http:///` — the +hub-client **root route**. Two failures follow: + +1. A fresh browser profile (the common case: the guest's proxy port is + ephemeral, so the origin is new) hits the `ProjectSetSetup` gate in + `App.tsx` (`needs-setup` / `needs-migration`). +2. Even past the gate, the app lands on `ProjectsHome`: the root route + carries no document coordinates, so the share handler + (`App.tsx:443`) never runs and the guest **never joins the + document**. + +The host's own boot URL is the share route with `ephemeral=true` +(`build_editor_boot_url`, bd-zf4ryvuq — see +[2026-08-07-preview-editor-skip-project-setup.md](2026-08-07-preview-editor-skip-project-setup.md)). +The guest needs the same URL shape, built against its local proxy +origin. + +**Approach:** + +1. **Host side** (`crates/quarto-preview`): the CLI's editor-mode + `on_ready` already computes the boot params (index doc id, file, + project name) for its own boot URL. It now also stashes them into a + `quarto_preview` OnceLock via `set_editor_boot(...)`, and + `GET /api/preview/config` gains an `editorBoot` field when stashed: + `{ "allowEdit": …, "editorBoot": { "indexDocId", "file", "name" } }`. + The stash happens in `on_ready`, which fires before the listener + binds, so no guest can fetch config before it is set. +2. **Guest side** (`crates/quarto/src/commands/preview.rs`, + `run_join`): after the tunnel binds, wait for `/health` through the + tunnel (the existing probe, now unconditional), then fetch + `/api/preview/config` once. If it carries `editorBoot`, build + `http:///#/share/?server=%2Fws&file=…&name=…&ephemeral=true` + (same route helper as the host builder) and print/open that; + otherwise fall back to `http:///` (viewer hosts, older hosts, + `--no-project` editor boots — all unchanged from today). + +The `ephemeral=true` flag makes the guest's hub-client capture the +flag at mount, silently establish the project-set root against `/ws` +(tunneled to the host), and skip the setup/migration/error gates — the +bd-zf4ryvuq machinery, now reached by guests too. No hub-client change +is needed; that support already shipped. + +**Why the config endpoint, not the ticket:** the ticket is minted +before the hub boots (so the banner can print ahead of the first +accept — `share.rs`), hence cannot carry the index doc id. The guest +already does HTTP probes through the tunnel for readiness, and +`/api/preview/config` is the established boot-time session-info +channel (bd-ov4gqk3m). Every `--share`-capable host already serves it, +and the doc id is already exposed to guests via `/health` — no new +trust surface. The viewer SPA reads only `allowEdit`; unknown fields +are ignored. + +## Work Items + +### Phase 1 — Tests first (TDD) + +- [x] `crates/quarto-preview/tests/integration/config_endpoint.rs`: + new test — server booted with `ui: PreviewUi::Editor`, on_ready + calls `quarto_preview::set_editor_boot(...)` (mirroring the CLI), + then `GET /api/preview/config` carries + `editorBoot { indexDocId, file, name }`. Also assert the field is + absent in the existing no-stash tests. Confirm fail (compile error — + the type/setter don't exist yet). Confirmed: + `error[E0425]: cannot find function set_editor_boot in crate + quarto_preview` + `E0422 EditorBootInfo`. +- [x] `crates/quarto/src/commands/preview.rs` tests: + - Guest URL builder: share-route shape, `ephemeral=true` last param, + `automerge:` prefix stripped, percent-encoding of file/name. + - `parse_editor_boot`: config body with `editorBoot` → `Some`; + without → `None`; malformed JSON → `None`; empty `indexDocId` / + `file` → `None`. + - Confirm fail. Confirmed: `E0425 build_guest_editor_url / + parse_editor_boot not found in this scope`. + +### Phase 2 — `quarto-preview` implementation + +- [x] `crates/quarto-preview/src/lib.rs`: public `EditorBootInfo` + (Serialize + Deserialize, camelCase wire names), crate OnceLock + + public `set_editor_boot` (first-writer-wins, same pattern as + `ALLOW_EDIT` / `PREVIEW_UI`), and `preview_config_handler` includes + `editorBoot` when stashed. +- [x] `cargo nextest run -p quarto-preview config_endpoint` — 3 passed + (including the new `config_reports_editor_boot_when_stashed`). + +### Phase 3 — CLI implementation (`crates/quarto/src/commands/preview.rs`) + +- [x] Extract `editor_share_route(index_doc_id, file, name)` (the hash + route, incl. `ephemeral=true`); `build_editor_boot_url` delegates so + host and guest share one route-shape source. +- [x] `build_guest_editor_url(SocketAddr, &EditorBootInfo)`. +- [x] `fetch_editor_boot(addr)` — hand-rolled one-shot + `GET /api/preview/config` (same no-new-deps style as + `health_get_ok`) + pure `parse_editor_boot(&[u8])`. +- [x] `run_join` restructure: probe `/health` unconditionally (15 s + budget, warn-on-timeout as today), then the config fetch; print the + final URL once; open the browser inline (the spawned probe task + goes away — the probe has already happened by print time). +- [x] Host editor-mode `on_ready`: stash `EditorBootInfo` when a share + file was picked (the `None` arm — `--no-project` — stashes nothing). +- [x] `cargo nextest run -p quarto preview` — 49 passed, including the + 5 new tests. + +### Phase 4 — Verification + +- [x] `cargo nextest run --workspace` (monorepo rule) — **10939 + passed, 0 failed**, 197 skipped. +- [x] `cargo xtask verify --skip-hub-build` (Rust-only change; + hub-client's ephemeral handling shipped with bd-zf4ryvuq) — all 14 + steps passed. +- [x] End-to-end through the real binaries — evidence below. + +#### End-to-end evidence + +Host (background, direct PID): + +``` +$ target/debug/q2 preview examples/websites/01-minimal --ui editor --share --no-browser + q2 preview — editor UI + session edits are ephemeral — pass --allow-edit to persist edits to disk + +Sharing this preview session (end-to-end encrypted via iroh). +… +q2 preview --join q2previewad762hujnc7b5xmlmlee6pysgdex65yzkkfq763sjch5wmfvgokaa… + + → http://127.0.0.1:51216/#/share/3gNGe3PqRHQ4EjyQFmNS2P94PMxC?server=%2Fws&file=index.qmd&name=01-minimal&ephemeral=true +``` + +Guest (same machine, `--no-browser`): + +``` +$ target/debug/q2 preview --join q2previewad762… --no-browser + + q2 preview — joining a shared session (end-to-end encrypted via iroh) + → http://127.0.0.1:51233/#/share/3gNGe3PqRHQ4EjyQFmNS2P94PMxC?server=%2Fws&file=index.qmd&name=01-minimal&ephemeral=true + + Press Ctrl-C to leave the session. + + ● connected via direct connection +``` + +The guest prints the **share route** on its own proxy port with the +**same doc id** as the host's boot URL and `ephemeral=true` — before +this change it printed the bare root URL `http://127.0.0.1:51233/`. + +Browser e2e (Playwright, headless Chromium, fresh profile — no +IDB/localStorage, i.e. the `needs-setup` path), driven at the guest's +printed URL: + +``` +PASS: .editor-container became visible +PASS: ProjectSetSetup never rendered +final url: http://127.0.0.1:51233/#/p//file/index.qmd +``` + +The final `#/p//file/index.qmd` URL proves the share handler +connected and loaded the document through the tunnel (it only +navigates after `connectAndLoadContents` succeeds). + +Control run against the same live guest proxy at the bare root URL +(the "before" behavior): `ProjectSetSetup` rendered, editor never +appeared (script exit 1) — confirming the fix, not the client build, +is what skips the gate. + +### Phase 5 — Bookkeeping + +- [x] Close bd-7htq16rx; keep this plan current. + +(No hub-client changelog entry: hub-client is untouched by this +change.) + +## Details + +### Design decisions + +1. **Boot info rides `/api/preview/config`, not a new endpoint.** The + endpoint already exists for boot-time session info, is served by + every preview server (viewer and editor), and is reachable through + the tunnel. Backward compatible in both directions: old hosts omit + the field (guest falls back to `/`, today's behavior); old guests + never fetch it. +2. **The CLI stashes, the server serves.** `pick_editor_file` and the + project-name derivation live in the CLI (they need `initial_page`, + which `PreviewConfig` deliberately doesn't carry); the server crate + just exposes the stash. `on_ready` fires before the listener binds + (`server.rs` contract), so the OnceLock is always set before any + guest can fetch. +3. **Guest prints one URL, after the probe.** Today the guest prints + `/` immediately and a spawned task gates the browser-open on + health. Printing before the probe would print the wrong (root) URL + for editor hosts; so the probe becomes sequential, the final URL + prints once, and the browser opens inline. Common case adds one + tunnel roundtrip (~ms); worst case (host still booting) waits out + the same 15 s budget the spawned probe had. +4. **`EditorBootInfo` is defined in `quarto-preview`** with both + `Serialize` and `Deserialize`; the guest (`quarto` crate) reuses + the same type — one wire-shape definition, no drift. +5. **`--no-project` editor hosts stash nothing** (there is no document + to join); guests land on `/` exactly as the host does. + +### Explicitly out of scope + +- `--no-project --share` editor hosts (degenerate: sharing an empty + editor; guest sees the same project selector the host sees). +- Changing the ticket wire format to carry boot params (impossible + without delaying the banner: the doc id doesn't exist at mint time). +- IDB accumulation on the guest for pinned-port join workflows (same + note as bd-zf4ryvuq; default guest ports are ephemeral). diff --git a/crates/quarto-preview/src/lib.rs b/crates/quarto-preview/src/lib.rs index 2a9157e37..a5f911e6c 100644 --- a/crates/quarto-preview/src/lib.rs +++ b/crates/quarto-preview/src/lib.rs @@ -75,6 +75,44 @@ static ALLOW_EDIT: OnceLock = OnceLock::new(); /// read by the SPA fallback handler. Same OnceLock pattern as above. static PREVIEW_UI: OnceLock = OnceLock::new(); +/// Editor-mode boot params for `--join` guests (bd-7htq16rx): the +/// share-route coordinates the host's own boot URL is built from. +/// Stashed by the CLI's editor-mode `on_ready` via [`set_editor_boot`] +/// and served at `GET /api/preview/config` as `editorBoot`, so a guest +/// can build the same share URL (with `ephemeral=true`) against its +/// local proxy origin and land straight in the document instead of +/// the project-set setup screen. Both `Serialize` (the host serves it) +/// and `Deserialize` (the `q2 preview --join` CLI parses it) — one +/// wire-shape definition, no drift. +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EditorBootInfo { + /// Index document id, exactly as `HubContext::index().document_id()` + /// returns it (the guest-side URL builder strips any `automerge:` + /// prefix, same as the host's). + pub index_doc_id: String, + /// The share route's `file` param — a `.qmd` path in the project. + pub file: String, + /// Project name shown in the guest's editor UI. + pub name: String, +} + +/// The session's editor boot params. Set once by the editor-mode +/// caller's `on_ready` (which fires before the listener binds, so no +/// client can fetch config ahead of it) and read by +/// `preview_config_handler`. Same first-writer-wins OnceLock pattern +/// as the other handler state above. +static EDITOR_BOOT: OnceLock = OnceLock::new(); + +/// Stash the editor-mode boot params so `GET /api/preview/config` can +/// hand them to `--join` guests (bd-7htq16rx). Editor-mode callers +/// invoke this from their `on_ready` when a share file was picked; +/// viewer mode and `--no-project` editor boots never do, and their +/// config answers without `editorBoot`. +pub fn set_editor_boot(info: EditorBootInfo) { + let _ = EDITOR_BOOT.set(info); +} + /// Which embedded frontend the preview server serves (`--ui`, /// live-share plan Phase 4, bd-jt1etjbn). /// @@ -578,9 +616,18 @@ pub fn extend_with_preview( /// `--allow-edit` CLI flag; the SPA uses it to enable or fully disable /// the inline block-editing surface, and the server independently /// enforces the same setting via [`quarto_hub::sync::DiskWritePolicy`]. +/// +/// `editorBoot` (bd-7htq16rx) is present only on editor-UI sessions +/// that stashed their share-route boot params via [`set_editor_boot`]; +/// `q2 preview --join` reads it through the tunnel to build the +/// guest's boot URL. The viewer SPA ignores the field. async fn preview_config_handler() -> Response { let allow_edit = ALLOW_EDIT.get().copied().unwrap_or(false); - axum::Json(serde_json::json!({ "allowEdit": allow_edit })).into_response() + let mut body = serde_json::json!({ "allowEdit": allow_edit }); + if let Some(boot) = EDITOR_BOOT.get() { + body["editorBoot"] = serde_json::to_value(boot).expect("EditorBootInfo always serializes"); + } + axum::Json(body).into_response() } /// The UI mode this session serves. Defaults to the viewer when `run()` diff --git a/crates/quarto-preview/tests/integration/config_endpoint.rs b/crates/quarto-preview/tests/integration/config_endpoint.rs index 4134bf4f1..7488ad6a0 100644 --- a/crates/quarto-preview/tests/integration/config_endpoint.rs +++ b/crates/quarto-preview/tests/integration/config_endpoint.rs @@ -123,6 +123,10 @@ async fn config_reports_read_only_and_doc_edits_never_reach_disk() { Some(false), "without --allow-edit the endpoint must report allowEdit: false; body was {body:?}" ); + assert!( + body.get("editorBoot").is_none(), + "editorBoot must be absent when nothing was stashed (bd-7htq16rx); body was {body:?}" + ); // Disk behavior: a document-side edit + explicit sync must leave // the file untouched (DiskWritePolicy::ReadOnly). @@ -151,6 +155,10 @@ async fn config_reports_allow_edit_and_doc_edits_persist_to_disk() { Some(true), "with --allow-edit the endpoint must report allowEdit: true; body was {body:?}" ); + assert!( + body.get("editorBoot").is_none(), + "editorBoot must be absent when nothing was stashed (bd-7htq16rx); body was {body:?}" + ); let edited = INITIAL_QMD.replace("Hello.", "Hello, edited in the browser."); edit_doc_text(&ctx, "index.qmd", &edited).await; @@ -166,3 +174,79 @@ async fn config_reports_allow_edit_and_doc_edits_persist_to_disk() { handle.abort(); let _ = handle.await; } + +/// bd-7htq16rx: an editor-UI host stashes its share-route boot params +/// (mirroring what the CLI's editor-mode `on_ready` does) and +/// `GET /api/preview/config` then carries them as `editorBoot`, so a +/// `--join` guest can build the same share URL (with `ephemeral=true`) +/// against its local proxy and land straight in the document. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn config_reports_editor_boot_when_stashed() { + let project = tempfile::TempDir::with_prefix("q2-preview-config-editor-boot-").unwrap(); + std::fs::write(project.path().join("index.qmd"), INITIAL_QMD).unwrap(); + let data = tempfile::TempDir::with_prefix("q2-preview-config-editor-boot-data-").unwrap(); + + let port = pick_free_port(); + let config = PreviewConfig { + host: "127.0.0.1".to_string(), + port, + project_root: Some(project.path().to_path_buf()), + single_file: None, + data_dir: data.path().to_path_buf(), + spa_dir_override: None, + engine_registry: None, + engine_policy: Default::default(), + resource_html_files: Vec::new(), + cache_dir: None, + allow_edit: false, + share: false, + ui: quarto_preview::PreviewUi::Editor, + }; + + let (ready_tx, ready_rx) = oneshot::channel::(); + let mut ready_tx = Some(ready_tx); + let handle = tokio::spawn(async move { + run_with_on_ready(config, move |ctx| { + // Mirror the CLI's editor-mode on_ready: stash the params + // the host's own share-route boot URL is built from. + let doc_id = ctx.index().document_id(); + quarto_preview::set_editor_boot(quarto_preview::EditorBootInfo { + index_doc_id: doc_id.clone(), + file: "index.qmd".to_string(), + name: "fixture-project".to_string(), + }); + if let Some(tx) = ready_tx.take() { + let _ = tx.send(doc_id); + } + }) + .await + }); + + let doc_id = tokio::time::timeout(Duration::from_secs(10), ready_rx) + .await + .expect("server reached on_ready within 10s") + .expect("on_ready callback fired"); + + let body = fetch_config(port).await; + assert_eq!( + body.get("allowEdit").and_then(|v| v.as_bool()), + Some(false), + "allowEdit keeps reporting independently of editorBoot; body was {body:?}" + ); + let boot = body + .get("editorBoot") + .expect("editorBoot must be present once stashed"); + assert_eq!( + boot.get("indexDocId").and_then(|v| v.as_str()), + Some(doc_id.as_str()), + "editorBoot names the hub's index document" + ); + assert_eq!(boot.get("file").and_then(|v| v.as_str()), Some("index.qmd")); + assert_eq!( + boot.get("name").and_then(|v| v.as_str()), + Some("fixture-project") + ); + + handle.abort(); + let _ = handle.await; +} diff --git a/crates/quarto/src/commands/preview.rs b/crates/quarto/src/commands/preview.rs index 1be516db8..75d8d8956 100644 --- a/crates/quarto/src/commands/preview.rs +++ b/crates/quarto/src/commands/preview.rs @@ -290,13 +290,25 @@ async fn run(args: PreviewArgs) -> Result<()> { quarto_preview::run_with_on_ready(config, move |ctx| { let paths: Vec = ctx.index().get_all_files().into_keys().collect(); let url = match pick_editor_file(initial_page.as_deref(), &paths) { - Some(file) => build_editor_boot_url( - &host_for_ready, - port, - &ctx.index().document_id(), - &file, - &project_name, - ), + Some(file) => { + // bd-7htq16rx: hand `--join` guests the same + // boot params via /api/preview/config so they + // boot the share route (skipping the project-set + // setup screen) instead of the editor's root + // route, which can never join the document. + quarto_preview::set_editor_boot(quarto_preview::EditorBootInfo { + index_doc_id: ctx.index().document_id(), + file: file.clone(), + name: project_name.clone(), + }); + build_editor_boot_url( + &host_for_ready, + port, + &ctx.index().document_id(), + &file, + &project_name, + ) + } None => { // No `.qmd` to seed the share route with (e.g. // --no-project): boot to the editor's project @@ -375,33 +387,45 @@ async fn run_join(args: JoinArgs) -> Result<()> { .await .map_err(join_bind_error)?; - let url = format!("http://{bound}/"); + // Resolve the boot URL through the tunnel before printing it. + // Readiness = the first successful GET /health *through the + // tunnel*: a bare TCP accept (host mode's readiness signal) would + // lie here — the local proxy accepts even when the host is + // unreachable — so only an end-to-end HTTP roundtrip proves the + // session is usable. Editor-UI hosts then carry their share-route + // boot params in /api/preview/config (bd-7htq16rx): boot the guest + // to the same share URL the host printed, ephemeral flag included, + // so hub-client skips project-set onboarding and joins the + // document. Viewer-mode and older hosts answer without + // `editorBoot` and keep the root URL. + const READY_TIMEOUT: Duration = Duration::from_secs(15); + let url = if wait_until_healthy(bound, READY_TIMEOUT).await { + info!(local = %bound, "shared session healthy through the tunnel"); + match fetch_editor_boot(bound).await { + Some(boot) => build_guest_editor_url(&bound, &boot), + None => format!("http://{bound}/"), + } + } else { + // We still consider a >15s startup a bug; printing the root + // URL anyway (rather than never) preserves the old behavior as + // a floor, and the warning gives the slow start visibility + // instead of leaving it silent. + tracing::warn!( + local = %bound, + timeout_secs = READY_TIMEOUT.as_secs(), + "shared session did not answer /health within the timeout; \ + the URL below may need a manual reload" + ); + format!("http://{bound}/") + }; + println!(" → {url}"); println!(); println!(" Press Ctrl-C to leave the session."); println!(); - // Browser-open readiness = the first successful GET /health *through - // the tunnel*. A bare TCP accept (host mode's readiness signal) - // would lie here: the local proxy accepts even when the host is - // unreachable, so only an end-to-end HTTP roundtrip proves the - // session is usable. if !args.no_browser { - let url_for_open = url.clone(); - tokio::spawn(async move { - const READY_TIMEOUT: Duration = Duration::from_secs(15); - if wait_until_healthy(bound, READY_TIMEOUT).await { - info!(local = %bound, "shared session healthy through the tunnel; opening browser"); - } else { - tracing::warn!( - local = %bound, - timeout_secs = READY_TIMEOUT.as_secs(), - "shared session did not answer /health within the timeout; \ - opening the browser anyway (it may need a manual reload)" - ); - } - open_browser_or_log(&url_for_open, false); - }); + open_browser_or_log(&url, false); } // Report status transitions ("connected via relay", "reconnecting…") @@ -519,6 +543,48 @@ async fn health_get_ok(addr: std::net::SocketAddr) -> bool { response.starts_with(b"HTTP/1.1 200") } +/// One raw HTTP/1.1 `GET /api/preview/config` against `addr`; the +/// parsed `editorBoot` params when the host is an editor-UI session +/// that stashed them (bd-7htq16rx). Hand-rolled for the same reason as +/// [`health_get_ok`]. Any failure — connect, non-200, malformed body, +/// absent field — is `None`, and the caller falls back to the root URL. +async fn fetch_editor_boot(addr: std::net::SocketAddr) -> Option { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let mut stream = tokio::net::TcpStream::connect(addr).await.ok()?; + stream + .write_all( + b"GET /api/preview/config HTTP/1.1\r\nHost: q2-preview-join\r\nConnection: close\r\n\r\n", + ) + .await + .ok()?; + let mut response = Vec::new(); + stream.read_to_end(&mut response).await.ok()?; + if !response.starts_with(b"HTTP/1.1 200") { + return None; + } + let body_start = response.windows(4).position(|w| w == b"\r\n\r\n")? + 4; + parse_editor_boot(&response[body_start..]) +} + +/// Parse the `editorBoot` field of a `/api/preview/config` body. +/// `None` for viewer-mode and older hosts (no field), for malformed +/// JSON, and for a field whose doc id or file is empty — a boot URL +/// built from those could never join the document. +fn parse_editor_boot(body: &[u8]) -> Option { + #[derive(serde::Deserialize)] + struct PreviewConfigWire { + #[serde(rename = "editorBoot")] + editor_boot: Option, + } + let boot = serde_json::from_slice::(body) + .ok()? + .editor_boot?; + if boot.index_doc_id.is_empty() || boot.file.is_empty() { + return None; + } + Some(boot) +} + /// Bind `host:0`, read the OS-assigned port, drop the listener. /// Returns the port number so the caller can pre-print the URL. fn probe_free_port(host: &str) -> Result { @@ -801,17 +867,47 @@ pub(crate) fn build_editor_boot_url( file: &str, project_name: &str, ) -> String { + format!( + "http://{host}:{port}{}", + editor_share_route(index_doc_id, file, project_name) + ) +} + +/// The hub-client share route both editor boot-URL builders emit (host +/// above, guest below). Single source for the route shape: the client's +/// validation requires `server` / `file` / `name`, and `ephemeral=true` +/// (bd-zf4ryvuq) marks the serving hub as a throwaway per-session +/// preview server so the client skips project-set onboarding. The doc +/// id travels bare: the client re-adds the `automerge:` prefix, and +/// `buildShareableUrl` on the TS side strips it symmetrically. +fn editor_share_route(index_doc_id: &str, file: &str, project_name: &str) -> String { let doc_id = index_doc_id .strip_prefix("automerge:") .unwrap_or(index_doc_id); format!( - "http://{host}:{port}/#/share/{}?server=%2Fws&file={}&name={}&ephemeral=true", + "/#/share/{}?server=%2Fws&file={}&name={}&ephemeral=true", percent_encode_component(doc_id), percent_encode_component(file), percent_encode_component(project_name), ) } +/// bd-7htq16rx: build the `--join` guest's boot URL — the same share +/// route the host prints, but against the guest's local proxy origin. +/// The params arrive from the host's `/api/preview/config` through the +/// tunnel ([`fetch_editor_boot`]); `server=%2Fws` resolves against the +/// page origin, i.e. the proxy, so the guest's hub-client syncs with +/// the host through the tunnel. +fn build_guest_editor_url( + addr: &std::net::SocketAddr, + boot: &quarto_preview::EditorBootInfo, +) -> String { + format!( + "http://{addr}{}", + editor_share_route(&boot.index_doc_id, &boot.file, &boot.name) + ) +} + /// Choose the share route's `file` param: the CLI-resolved initial /// page when there is one, else the lexicographically first `.qmd` /// known to the index (its files map is unordered; taking the minimum @@ -1103,6 +1199,79 @@ mod tests { ); } + // ────────────────────────────────────────────────────────────── + // bd-7htq16rx: `--join` guests of an editor-UI host boot the same + // share route (ephemeral flag included) so hub-client skips + // project-set onboarding and joins the document. The boot params + // arrive from the host's `/api/preview/config` through the tunnel. + // ────────────────────────────────────────────────────────────── + + fn guest_addr() -> std::net::SocketAddr { + "127.0.0.1:8080".parse().unwrap() + } + + #[test] + fn guest_editor_url_is_share_route_with_ephemeral_flag() { + let boot = quarto_preview::EditorBootInfo { + index_doc_id: "4XyZabc123".to_string(), + file: "posts/intro.qmd".to_string(), + name: "My Project".to_string(), + }; + assert_eq!( + build_guest_editor_url(&guest_addr(), &boot), + "http://127.0.0.1:8080/#/share/4XyZabc123?server=%2Fws&file=posts%2Fintro.qmd&name=My%20Project&ephemeral=true", + ); + } + + #[test] + fn guest_editor_url_strips_automerge_prefix() { + let boot = quarto_preview::EditorBootInfo { + index_doc_id: "automerge:4XyZ".to_string(), + file: "a.qmd".to_string(), + name: "p".to_string(), + }; + let url = build_guest_editor_url(&guest_addr(), &boot); + assert!( + url.contains("#/share/4XyZ?"), + "the route wants the bare doc id; got {url}" + ); + assert!( + !url.contains("automerge"), + "the automerge: prefix must not leak into the URL; got {url}" + ); + } + + #[test] + fn parse_editor_boot_reads_config_body() { + let body = br#"{"allowEdit":false,"editorBoot":{"indexDocId":"4XyZ","file":"index.qmd","name":"proj"}}"#; + let boot = parse_editor_boot(body).expect("editorBoot parses"); + assert_eq!(boot.index_doc_id, "4XyZ"); + assert_eq!(boot.file, "index.qmd"); + assert_eq!(boot.name, "proj"); + } + + #[test] + fn parse_editor_boot_absent_without_the_field() { + // Viewer-mode (and older) hosts answer config without + // editorBoot — the guest falls back to the root URL. + assert_eq!(parse_editor_boot(br#"{"allowEdit":false}"#), None); + } + + #[test] + fn parse_editor_boot_rejects_malformed_or_empty() { + assert_eq!(parse_editor_boot(b"not json"), None); + assert_eq!(parse_editor_boot(b""), None); + // A boot URL with an empty doc id or file can never join. + assert_eq!( + parse_editor_boot(br#"{"editorBoot":{"indexDocId":"","file":"index.qmd","name":"p"}}"#), + None + ); + assert_eq!( + parse_editor_boot(br#"{"editorBoot":{"indexDocId":"4XyZ","file":"","name":"p"}}"#), + None + ); + } + #[test] fn pick_editor_file_prefers_initial_page() { let files = vec!["about.qmd".to_string(), "index.qmd".to_string()]; From 31a725ab3590cfd6fbde8a0db31202aa1a81cb52 Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:40:42 +0100 Subject: [PATCH 11/22] fix(quarto-hub): pin automerge fork_at fix, contain collector panics (bd-4062e8tn) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit q2 preview --allow-edit --share aborted after a few minutes: the periodic sync's sync_document hit a MissingOps panic at automerge-0.10.0 op_set2/change/collector.rs:761, the unwind escaped with_document, samod's WithDocGuard::drop panicked on top ("dropped without comitting"), and the destructor panic during unwind aborted the process. Root cause is upstream automerge#1327: fork_at's dependency walk marks hashes as seen when popped, not when scheduled, so some DAG shapes (sync_document's every-5s fork_at(checkpoint) + merge against splice-heavy browser edits produces them) schedule a dep twice; the duplicate change metadata makes the change collector return MissingOps and its unwrap panics. Verified with the issue's deterministic repro, which panics identically on our pinned 0.10.0 and passes with PR #1366's fix; kept as fork_at_after_sync_and_merge_does_not_panic. Two-part fix: - [patch.crates-io] pins automerge to quarto-dev/automerge rust/automerge-0.10.0-fork-at-missingops = the 0.10.0 tag + the #1366 cherry-pick (one-hunk schedule-time seen-marking + its regression test; the fork's own suite passes). Remove once upstream merges #1366 and ships a release. - sync_document now runs its automerge work under run_contained: catch_unwind + in-place heal (save → load rebuilds clean internal indices; actor id restored explicitly since save/load doesn't round-trip it), turning any future collector-class panic into an ordinary sync error instead of a process abort. The collector panic paths are all read paths, so the document is never half-mutated by the panic itself. Verified: containment unit tests (panic caught, doc healed, actor and heads preserved; Ok/Err pass through untouched); cargo nextest run --workspace 10942 passed; cargo xtask verify --skip-hub-build green (automerge is not in the hub-client/WASM dependency chain). cargo tree confirms a single automerge source across quarto-hub, samod, and samod-core. --- Cargo.lock | 6 +- Cargo.toml | 7 + ...2026-08-07-automerge-fork-at-missingops.md | 115 +++++ crates/quarto-hub/src/sync.rs | 455 +++++++++++++----- 4 files changed, 462 insertions(+), 121 deletions(-) create mode 100644 claude-notes/plans/2026-08-07-automerge-fork-at-missingops.md diff --git a/Cargo.lock b/Cargo.lock index 2192f57b9..c0b3b541d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -302,8 +302,7 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "automerge" version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09b78abcbba93428b9465b26cb2816a5b4654cce507f099a84a8c1b311cb3633" +source = "git+https://github.com/quarto-dev/automerge?rev=152d98cb3db771e8ecf8ac7465f2b79ed64cca07#152d98cb3db771e8ecf8ac7465f2b79ed64cca07" dependencies = [ "cfg-if", "flate2", @@ -2327,8 +2326,7 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "hexane" version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f4ecba0bb4e14df997df7cab6d1b584c6432d8865cf2adfced814706d715a7b" +source = "git+https://github.com/quarto-dev/automerge?rev=152d98cb3db771e8ecf8ac7465f2b79ed64cca07#152d98cb3db771e8ecf8ac7465f2b79ed64cca07" dependencies = [ "leb128", "thiserror 2.0.18", diff --git a/Cargo.toml b/Cargo.toml index b1817b9e1..0391661f8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -281,6 +281,13 @@ verbose_bit_mask = "warn" verbose_file_reads = "warn" [patch.crates-io] +# automerge#1327 (MissingOps panic: fork_at's dependency traversal can +# schedule a dep twice in some DAG shapes; the duplicate change metadata +# makes the change collector return MissingOps and its unwrap panics — +# hit by quarto-hub's sync_document every-5s fork_at, aborting preview +# via samod's WithDocGuard). 0.10.0 tag + the #1366 fix cherry-picked +# (bd-4062e8tn). Remove once upstream merges #1366 and ships a release. +automerge = { git = "https://github.com/quarto-dev/automerge", rev = "152d98cb3db771e8ecf8ac7465f2b79ed64cca07" } lua-src = { path = "crates/lua-src-wasm" } # Neutralize tree-sitter-language's upstream wasm32 stdio/stdlib/string C # stubs so wasm-c-shim is the single source of truth for C stdlib symbols diff --git a/claude-notes/plans/2026-08-07-automerge-fork-at-missingops.md b/claude-notes/plans/2026-08-07-automerge-fork-at-missingops.md new file mode 100644 index 000000000..7e8d2b928 --- /dev/null +++ b/claude-notes/plans/2026-08-07-automerge-fork-at-missingops.md @@ -0,0 +1,115 @@ +# Diagnosis: preview hub abort — automerge `fork_at` MissingOps (upstream #1327) + +Strand: bd-4062e8tn + +## The crash (as reported) + +`q2 preview --allow-edit --share .` aborts after a few minutes: + +1. `automerge-0.10.0/src/op_set2/change/collector.rs:761:40`: + `called Result::unwrap() on an Err value: MissingOps` — inside + `run_periodic_sync` → `sync_document`'s `with_document` closure. +2. The unwind escapes the closure, so samod's + `WithDocGuard::drop` panics (`WithDocGuard dropped without + comitting`, document_actor.rs:721). +3. "panic in a destructor during cleanup" → non-unwinding panic → + **process abort**. + +## Root cause (verified) + +This is upstream [automerge#1327](https://github.com/automerge/automerge/issues/1327) +with the root-cause fix in open PR +[automerge#1366](https://github.com/automerge/automerge/pull/1366). + +`Automerge::fork_at` (automerge.rs:578-602 in 0.10.0) walks backwards +from the requested heads, marking a hash as seen only when it is +**popped** from the stack, not when it is **scheduled**. In some DAG +shapes the same dependency is scheduled twice before either copy is +popped; the duplicate produces two `BuildChangeMetadata` entries for +the same `(actor, seq, op range)`. The change collector +(`ChangeCollector::for_hashes` → `from_build_meta_inner`) fills one +builder, then finds the duplicate's slots empty → `Err(MissingOps)` → +the `unwrap()` at collector.rs:761 panics. + +The trigger in q2: `sync_document`'s every-5-seconds +`fork_at(last_sync_heads)` + `update_text` + `merge` cycle +([sync.rs:111](../../crates/quarto-hub/src/sync.rs)) interleaved with +splice-heavy browser edits arriving over sync — precisely the issue's +reported pattern ("file watcher uses `fork_at(save_heads)` to +reconcile external disk changes"). The checkpoint heads are multi-head +after any merge, which is what gives the walk its diamond shape. This +is why it takes "a few minutes": the panic needs the right topology, +not a specific duration. + +**Not** corruption: `save()`/`load()` round-trips fine (the export +path bypasses the collector). The bug is deterministic in the +traversal, so once the DAG has the shape, `fork_at` over those heads +panics every time — containment alone cannot restore sync for the +affected document. + +## Verification evidence + +- The issue's deterministic reproduction (char-at-a-time `splice_text` + from one peer, fork+merge from another, real `sync::State` message + exchange, `fork_at` over historical heads every 200 chars) panics on + our pinned automerge 0.10.0 with the **identical** signature + (`collector.rs:761:40: MissingOps`). Adapted as + `sync::tests::repro_fork_at_missing_ops` in sync.rs (currently + failing — TDD red). +- Applying #1366's one-hunk fix (mark dep hashes seen at schedule + time) to a scratch copy of 0.10.0 and `[patch.crates-io]`-ing it in: + the repro **passes**, and the full quarto-hub + quarto-preview + suites (553 tests) stay green. +- Earlier hypotheses eliminated empirically: pure fork/merge patterns + (500 rounds, linear or concurrent branches) don't trigger it — + real `sync::State` message exchange plus the multi-head checkpoint + walk is required; duplicate actor ids produce a *clean* + `Err(DuplicateSeqNumber)` from the batch apply (rejected before any + application), not MissingOps. + +## Fix tracks + +### A. Root cause: carry #1366 via a git-pinned automerge (recommended) + +`[patch.crates-io] automerge = { git = "…", rev = "…" }` pointing at a +fork with the #1366 commit rebased on the 0.10.0 tag. Precedent in +this workspace: the `runtimelib` git pin (cscheid/runtimed, by rev). +nteract carries the same patch rebased on 0.10.0 for the same reason. +The pin covers the whole graph (samod uses `automerge = { workspace = +true }`; a single automerge 0.10.0 is in the lockfile). Remove once +upstream merges #1366 and ships a release. + +Open question: where the fork lives (quarto-dev fork of automerge-rs +vs. pinning rgbkrk's branch by rev — his branch is main-based and was +force-pushed once, so rev-pinning is mandatory; a 0.10.0-based branch +is safer). + +### B. Blast radius: containment in `sync_document` (recommended as well) + +Even with A, the collector `unwrap` remains for other error classes +(PR #1360, error propagation, is still open upstream), and samod's +`WithDocGuard` turns **any** panic inside `with_document` into a +process abort. Wrap the `sync_document` closure body in +`catch_unwind(AssertUnwindSafe(..))`; on panic, heal the document in +place (`*doc = Automerge::load(&doc.save())` — rebuilds clean internal +indices, preserves heads and actor id) and return a sync error instead +of crashing. The collector panic paths are all read paths +(`fork_at`/`get_change_by_hash`/`generate_sync_message` build new +docs or read; the mutating `apply_changes` does not run the +collector), so the document is never half-mutated by the panic itself. + +### Explicitly rejected + +- **Avoiding `fork_at` / single-head checkpoints**: breaks + sync_document's merge semantics; the diamond lives inside ordinary + multi-head history anyway. +- **Containment only**: the traversal bug is deterministic per DAG + shape — the affected doc would panic every 5 s cycle forever + (caught, but sync dead for that doc). + +## State of the working tree + +- `crates/quarto-hub/src/sync.rs`: the deterministic repro test + (TDD red against unpatched automerge). +- Scratch under `target/repro-missingops/` (gitignored): patched + automerge copy, live-repro driver, fixture copy. diff --git a/crates/quarto-hub/src/sync.rs b/crates/quarto-hub/src/sync.rs index 5721e3730..b3a22213e 100644 --- a/crates/quarto-hub/src/sync.rs +++ b/crates/quarto-hub/src/sync.rs @@ -15,7 +15,7 @@ use std::path::{Component, Path, PathBuf}; use std::str::FromStr; -use automerge::{ROOT, ReadDoc, transaction::Transactable}; +use automerge::{Automerge, ROOT, ReadDoc, transaction::Transactable}; use samod::{DocHandle, DocumentId, Repo}; use tracing::{debug, warn}; @@ -45,6 +45,63 @@ pub enum DiskWritePolicy { ReadOnly, } +/// Panic containment for automerge work inside samod's `with_document` +/// (bd-4062e8tn). samod's `WithDocGuard` panics in its `Drop` when the +/// closure unwinds without committing, and a destructor panic during +/// unwind aborts the whole process — so any panic inside automerge +/// (the change-collector `unwrap` of upstream automerge#1327's class) +/// would take down the server. Catch it instead, heal the document in +/// place, and let the caller surface an ordinary sync error. +/// +/// The heal is a `save` → `load` round-trip: the export path bypasses +/// the change collector (the doc data is consistent even when the +/// collector panics — the panic paths are all read paths), and loading +/// rebuilds clean internal indices. Heads and actor id are preserved, +/// so samod's guard commit observes no change and the doc's identity +/// stays stable for sync peers. +/// +/// Returns `Some(result)` when `f` completes — its `Err` is an +/// ordinary sync error, not a panic — and `None` when a panic was +/// caught (and the doc healed). +fn run_contained( + doc: &mut Automerge, + f: impl FnOnce(&mut Automerge) -> Result, +) -> Option> { + match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(&mut *doc))) { + Ok(result) => Some(result), + Err(payload) => { + let msg = payload + .downcast_ref::<&str>() + .map(|s| (*s).to_string()) + .or_else(|| payload.downcast_ref::().cloned()) + .unwrap_or_else(|| "unknown panic".to_string()); + // Heal in place; if save/load itself fails there is nothing + // more to do — the doc is left as-is and the caller still + // gets the panic signal (None) rather than a crash. The + // actor id is a per-session property that save/load does + // NOT round-trip, so restore it explicitly — samod and sync + // peers rely on the doc's actor staying stable. + let actor = doc.get_actor().clone(); + let healed = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let bytes = doc.save(); + Automerge::load(&bytes).map(|mut fresh| { + fresh.set_actor(actor); + fresh + }) + })); + if let Ok(Ok(fresh)) = healed { + *doc = fresh; + } + warn!( + error = %msg, + "automerge panicked during sync; document healed via save/load \ + (containment bd-4062e8tn — please report)" + ); + None + } + } +} + /// Synchronize a single document with its corresponding filesystem file. /// /// This implements the unified sync algorithm: @@ -84,129 +141,145 @@ pub fn sync_document( })?; let result = doc_handle.with_document(|doc| { - // 1. Get sync checkpoint (use current heads if none exists or invalid) - let checkpoint_heads = sync_state.get_heads(&doc_id); - let last_sync_heads = checkpoint_heads - .filter(|heads| { - // Validate that all checkpoint heads exist in document history - heads.iter().all(|h| doc.get_change_by_hash(h).is_some()) - }) - .unwrap_or_else(|| doc.get_heads()); - - let current_heads = doc.get_heads(); - let heads_unchanged = last_sync_heads == current_heads; - - // Check if filesystem content matches what we synced last time - let last_content_hash = sync_state.get_content_hash(&doc_id); - let fs_content_hash = sha256_hash(&fs_content); - let fs_unchanged = last_content_hash == Some(fs_content_hash.as_str()); - - // Early exit: if nothing changed, we're done - if heads_unchanged && fs_unchanged { - debug!(doc_id = %doc_id, "No changes detected, skipping sync"); - return Ok(SyncResult::NoChanges); - } - - // 3. Fork at sync checkpoint (with fallback if fork_at fails) - let mut forked = doc.fork_at(&last_sync_heads).unwrap_or_else(|e| { - warn!( - doc_id = %doc_id, - error = %e, - "fork_at failed, falling back to current state" - ); - doc.fork() - }); - - // 4. Apply filesystem content to fork - let text_obj = forked - .get(ROOT, "text") - .map_err(|e| Error::Sync(format!("failed to get text object: {:?}", e)))? - .ok_or_else(|| { - Error::Sync(format!( - "document {} has no text field - was it initialized correctly?", - doc_id - )) - })? - .1; - - forked - .transact::<_, _, automerge::AutomergeError>(|tx| { - tx.update_text(&text_obj, &fs_content)?; - Ok(()) - }) - .map_err(|e| Error::Sync(format!("failed to update text in fork: {:?}", e)))?; - - // 5. Merge fork back into main document - doc.merge(&mut forked) - .map_err(|e| Error::Sync(format!("failed to merge fork: {:?}", e)))?; - - // 6. Read merged content and write back to filesystem - let merged_text_obj = doc - .get(ROOT, "text") - .map_err(|e| Error::Sync(format!("failed to get merged text object: {:?}", e)))? - .ok_or_else(|| Error::Sync("merged document has no text field".to_string()))? - .1; - - let merged_content = doc - .text(&merged_text_obj) - .map_err(|e| Error::Sync(format!("failed to read merged text: {:?}", e)))?; - - // Determine what kind of sync happened - let result_type = if !heads_unchanged && !fs_unchanged { - SyncResult::BothChanged { - merged_len: merged_content.len(), - } - } else if !heads_unchanged { - SyncResult::AutomergeChanged { - new_len: merged_content.len(), - } - } else { - SyncResult::FilesystemChanged { - new_len: merged_content.len(), + // bd-4062e8tn: run the automerge work under panic containment — + // a panic anywhere in here (the change-collector unwrap class, + // upstream automerge#1327) must never unwind into samod's + // WithDocGuard, whose Drop would abort the process. On a caught + // panic the doc is healed via save/load and this cycle reports + // an ordinary sync error; the next cycle retries on clean + // indices. + run_contained(doc, |doc| { + // 1. Get sync checkpoint (use current heads if none exists or invalid) + let checkpoint_heads = sync_state.get_heads(&doc_id); + let last_sync_heads = checkpoint_heads + .filter(|heads| { + // Validate that all checkpoint heads exist in document history + heads.iter().all(|h| doc.get_change_by_hash(h).is_some()) + }) + .unwrap_or_else(|| doc.get_heads()); + + let current_heads = doc.get_heads(); + let heads_unchanged = last_sync_heads == current_heads; + + // Check if filesystem content matches what we synced last time + let last_content_hash = sync_state.get_content_hash(&doc_id); + let fs_content_hash = sha256_hash(&fs_content); + let fs_unchanged = last_content_hash == Some(fs_content_hash.as_str()); + + // Early exit: if nothing changed, we're done + if heads_unchanged && fs_unchanged { + debug!(doc_id = %doc_id, "No changes detected, skipping sync"); + return Ok(SyncResult::NoChanges); } - }; - // Write merged content back to filesystem (only if it differs and - // the policy allows the automerge → disk direction) - let merged_content_hash = sha256_hash(&merged_content); - if merged_content_hash != fs_content_hash { - match policy { - DiskWritePolicy::WriteBack => { - std::fs::write(file_path, &merged_content).map_err(|e| { - Error::Sync(format!( - "failed to write merged content to {}: {}", - file_path.display(), - e - )) - })?; - debug!( - doc_id = %doc_id, - path = %file_path.display(), - "Wrote merged content to filesystem" - ); + // 3. Fork at sync checkpoint (with fallback if fork_at fails) + let mut forked = doc.fork_at(&last_sync_heads).unwrap_or_else(|e| { + warn!( + doc_id = %doc_id, + error = %e, + "fork_at failed, falling back to current state" + ); + doc.fork() + }); + + // 4. Apply filesystem content to fork + let text_obj = forked + .get(ROOT, "text") + .map_err(|e| Error::Sync(format!("failed to get text object: {:?}", e)))? + .ok_or_else(|| { + Error::Sync(format!( + "document {} has no text field - was it initialized correctly?", + doc_id + )) + })? + .1; + + forked + .transact::<_, _, automerge::AutomergeError>(|tx| { + tx.update_text(&text_obj, &fs_content)?; + Ok(()) + }) + .map_err(|e| Error::Sync(format!("failed to update text in fork: {:?}", e)))?; + + // 5. Merge fork back into main document + doc.merge(&mut forked) + .map_err(|e| Error::Sync(format!("failed to merge fork: {:?}", e)))?; + + // 6. Read merged content and write back to filesystem + let merged_text_obj = doc + .get(ROOT, "text") + .map_err(|e| Error::Sync(format!("failed to get merged text object: {:?}", e)))? + .ok_or_else(|| Error::Sync("merged document has no text field".to_string()))? + .1; + + let merged_content = doc + .text(&merged_text_obj) + .map_err(|e| Error::Sync(format!("failed to read merged text: {:?}", e)))?; + + // Determine what kind of sync happened + let result_type = if !heads_unchanged && !fs_unchanged { + SyncResult::BothChanged { + merged_len: merged_content.len(), + } + } else if !heads_unchanged { + SyncResult::AutomergeChanged { + new_len: merged_content.len(), } - DiskWritePolicy::ReadOnly => { - debug!( - doc_id = %doc_id, - path = %file_path.display(), - "Disk write-back disabled; file left untouched" - ); + } else { + SyncResult::FilesystemChanged { + new_len: merged_content.len(), + } + }; + + // Write merged content back to filesystem (only if it differs and + // the policy allows the automerge → disk direction) + let merged_content_hash = sha256_hash(&merged_content); + if merged_content_hash != fs_content_hash { + match policy { + DiskWritePolicy::WriteBack => { + std::fs::write(file_path, &merged_content).map_err(|e| { + Error::Sync(format!( + "failed to write merged content to {}: {}", + file_path.display(), + e + )) + })?; + debug!( + doc_id = %doc_id, + path = %file_path.display(), + "Wrote merged content to filesystem" + ); + } + DiskWritePolicy::ReadOnly => { + debug!( + doc_id = %doc_id, + path = %file_path.display(), + "Disk write-back disabled; file left untouched" + ); + } } } - } - // 7. Update sync checkpoint. Under ReadOnly the file was not - // written, so the checkpoint must record the hash of what is - // actually on disk — otherwise the next sync would misread the - // unchanged file as a fresh filesystem edit. - let checkpoint_hash = match policy { - DiskWritePolicy::WriteBack => &merged_content_hash, - DiskWritePolicy::ReadOnly => &fs_content_hash, - }; - let new_heads = doc.get_heads(); - sync_state.set_checkpoint(&doc_id, &new_heads, checkpoint_hash); + // 7. Update sync checkpoint. Under ReadOnly the file was not + // written, so the checkpoint must record the hash of what is + // actually on disk — otherwise the next sync would misread the + // unchanged file as a fresh filesystem edit. + let checkpoint_hash = match policy { + DiskWritePolicy::WriteBack => &merged_content_hash, + DiskWritePolicy::ReadOnly => &fs_content_hash, + }; + let new_heads = doc.get_heads(); + sync_state.set_checkpoint(&doc_id, &new_heads, checkpoint_hash); - Ok(result_type) + Ok(result_type) + }) + .unwrap_or_else(|| { + Err(Error::Sync(format!( + "automerge panicked while syncing document {doc_id}; \ + the document was healed via save/load and sync will \ + retry on the next cycle" + ))) + }) }); match &result { @@ -933,6 +1006,154 @@ mod tests { doc } + /// Panic containment (bd-4062e8tn): a panic inside the automerge + /// closure must be caught, the doc healed in place (save → load + /// rebuilds clean internal indices; heads and actor id preserved), + /// and the outcome signaled as `None` — never an unwind escaping + /// into samod's `WithDocGuard` (whose Drop would abort the process). + #[test] + fn containment_catches_panic_and_heals_doc() { + let mut doc = create_doc_with_text("hello\n"); + let actor_before = doc.get_actor().clone(); + let heads_before = doc.get_heads(); + + let out: Option> = + run_contained(&mut doc, |_| panic!("simulated collector panic")); + assert!(out.is_none(), "a panic is contained, not propagated"); + + // Healed in place: same actor, same heads, fully functional. + assert_eq!(doc.get_actor(), &actor_before); + assert_eq!(doc.get_heads(), heads_before); + let text_obj = doc.get(ROOT, "text").unwrap().unwrap().1; + assert_eq!(doc.text(&text_obj).unwrap(), "hello\n"); + doc.transact::<_, _, automerge::AutomergeError>(|tx| { + tx.update_text(&text_obj, "hello again\n")?; + Ok(()) + }) + .unwrap(); + // Collector paths work after the heal. + let _ = doc.fork_at(&heads_before).unwrap(); + } + + /// Containment must not disturb the ordinary paths: `Ok` values and + /// ordinary `Err`s pass through untouched (an `Err` is not a panic). + #[test] + fn containment_passes_through_results_and_errors() { + let mut doc = create_doc_with_text("hello\n"); + let out = run_contained(&mut doc, |_| Ok(42)); + assert!(matches!(out, Some(Ok(42)))); + let out: Option> = run_contained(&mut doc, |_| Err(Error::Sync("nope".into()))); + assert!(matches!(out, Some(Err(_)))); + } + + /// Regression test for upstream automerge#1327 (MissingOps panic in + /// `op_set2/change/collector.rs` after `fork_at` + `merge`), adapted + /// from the issue's reproduction; covers the `[patch.crates-io]` + /// automerge pin carrying the #1366 fix (bd-4062e8tn) — if the pin + /// is removed before upstream ships the fix, this goes red again. + /// `sync_document`'s fork-at-checkpoint pattern hits this once + /// browser splices and sync/merge activity give the DAG the right + /// shape. + #[test] + fn fork_at_after_sync_and_merge_does_not_panic() { + use automerge::{ActorId, AutoCommit, sync, sync::SyncDoc}; + + fn sync_docs( + a: &mut AutoCommit, + sa: &mut sync::State, + b: &mut AutoCommit, + sb: &mut sync::State, + ) { + for _ in 0..20 { + let mut progressed = false; + if let Some(msg) = a.sync().generate_sync_message(sa) { + b.sync().receive_sync_message(sb, msg).unwrap(); + progressed = true; + } + if let Some(msg) = b.sync().generate_sync_message(sb) { + a.sync().receive_sync_message(sa, msg).unwrap(); + progressed = true; + } + if !progressed { + break; + } + } + } + + fn sync_one( + from: &mut AutoCommit, + sf: &mut sync::State, + to: &mut AutoCommit, + st: &mut sync::State, + ) { + if let Some(msg) = from.sync().generate_sync_message(sf) { + to.sync().receive_sync_message(st, msg).unwrap(); + } + if let Some(msg) = to.sync().generate_sync_message(st) { + from.sync().receive_sync_message(sf, msg).unwrap(); + } + } + + let mut doc_b = AutoCommit::new(); + doc_b.set_actor(ActorId::from(b"peer-b" as &[u8])); + let text = doc_b.put_object(ROOT, "source", ObjType::Text).unwrap(); + doc_b.splice_text(&text, 0, 0, "# cell\n").unwrap(); + doc_b.commit(); + let initial_heads = doc_b.get_heads(); + + let mut doc_a = AutoCommit::new(); + doc_a.set_actor(ActorId::from(b"peer-a" as &[u8])); + let mut sa = sync::State::new(); + let mut sb = sync::State::new(); + sync_docs(&mut doc_b, &mut sb, &mut doc_a, &mut sa); + + let mut checkpoint_heads = vec![initial_heads]; + + for i in 0..1000u32 { + // Peer A types one character. + let pos = doc_a.text(&text).unwrap().len(); + doc_a + .splice_text( + &text, + pos, + 0, + &format!("{}", (b'a' + (i % 26) as u8) as char), + ) + .unwrap(); + doc_a.commit(); + + if i % 10 == 0 { + sync_one(&mut doc_a, &mut sa, &mut doc_b, &mut sb); + + // Peer B fork+merge. + let mut fork = doc_b.fork(); + fork.set_actor(ActorId::from(format!("b:f{}", i).as_bytes())); + fork.put(ROOT, "counter", (i / 10) as i64).unwrap(); + fork.commit(); + doc_b.merge(&mut fork).unwrap(); + + sync_one(&mut doc_b, &mut sb, &mut doc_a, &mut sa); + } + + if i % 100 == 0 && i > 0 { + checkpoint_heads.push(doc_b.get_heads()); + } + + // Every 200 chars: fork_at historical + merge + sync. + if i % 200 == 0 && i > 0 { + let old = &checkpoint_heads[checkpoint_heads.len() / 2]; + let mut fork = doc_b.fork_at(old).unwrap(); + fork.set_actor(ActorId::from(format!("b:file{}", i).as_bytes())); + fork.put(ROOT, "marker", format!("save-{}", i)).unwrap(); + fork.commit(); + doc_b.merge(&mut fork).unwrap(); + + // Panics pre-fix (MissingOps in the change collector): + let _ = doc_b.sync().generate_sync_message(&mut sb); + } + } + } + /// Helper to read text from doc handle fn read_text_from_handle(handle: &DocHandle) -> String { handle.with_document(|doc| { From 265810ea7c823a62506e4f2320d72619cf092167 Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Fri, 7 Aug 2026 20:18:59 +0100 Subject: [PATCH 12/22] fix(quarto-hub): stop ReadOnly periodic sync clobbering live doc edits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under DiskWritePolicy::ReadOnly (q2 preview without --allow-edit), the sync checkpoint pairs current heads with the *disk* content hash, so the doc state at those heads has diverged from the never-written-back file. The next sync's fork-apply then rewrote the fork to the stale disk content and the merge deleted every doc-side edit older than the previous checkpoint — with the 5s periodic sync, edits in a --ui editor --share session vanished on host and guest alike within a tick or two. Gate the fork-apply-merge on the filesystem actually having changed. With no disk delta there is nothing to merge; the checkpoint still advances heads so the next sync doesn't re-fire, and a later disk edit still converges the doc to disk per the documented ReadOnly semantics. WriteBack is unaffected: its checkpoint pairs heads with the content written back, so the fork-apply is already a no-op when only the doc changed. Adds regression test test_sync_readonly_repeated_doc_edits_are_not_clobbered (fails pre-fix: the second periodic sync clobbers the first edit). Existing ReadOnly tests — disk-authoritative convergence, no-write-back, repeat-sync stability — all still pass. --- crates/quarto-hub/src/sync.rs | 140 ++++++++++++++++++++++++++-------- 1 file changed, 109 insertions(+), 31 deletions(-) diff --git a/crates/quarto-hub/src/sync.rs b/crates/quarto-hub/src/sync.rs index b3a22213e..abaead734 100644 --- a/crates/quarto-hub/src/sync.rs +++ b/crates/quarto-hub/src/sync.rs @@ -114,6 +114,11 @@ fn run_contained( /// [`DiskWritePolicy::ReadOnly`], where disk stays authoritative) /// 7. Update sync checkpoint /// +/// Steps 3-5 are skipped under [`DiskWritePolicy::ReadOnly`] when the +/// filesystem content is unchanged since the last checkpoint: with no +/// disk delta there is nothing to merge, and applying the stale (never +/// written-back) disk content would revert live doc-side edits. +/// /// # Arguments /// * `doc_handle` - Handle to the automerge document /// * `file_path` - Path to the filesystem file @@ -172,38 +177,59 @@ pub fn sync_document( return Ok(SyncResult::NoChanges); } - // 3. Fork at sync checkpoint (with fallback if fork_at fails) - let mut forked = doc.fork_at(&last_sync_heads).unwrap_or_else(|e| { - warn!( - doc_id = %doc_id, - error = %e, - "fork_at failed, falling back to current state" - ); - doc.fork() - }); - - // 4. Apply filesystem content to fork - let text_obj = forked - .get(ROOT, "text") - .map_err(|e| Error::Sync(format!("failed to get text object: {:?}", e)))? - .ok_or_else(|| { - Error::Sync(format!( - "document {} has no text field - was it initialized correctly?", - doc_id - )) - })? - .1; - - forked - .transact::<_, _, automerge::AutomergeError>(|tx| { - tx.update_text(&text_obj, &fs_content)?; - Ok(()) - }) - .map_err(|e| Error::Sync(format!("failed to update text in fork: {:?}", e)))?; + // Steps 3-5 merge the filesystem delta into the document. + // They must only run when the filesystem actually changed: + // the fork-apply rewrites the fork from its checkpoint state + // to the current disk content, and under ReadOnly the + // checkpoint pairs current heads with the *disk* hash (the + // file is never written back), so the doc state at those + // heads has diverged from the disk content. Applying that + // stale disk content anyway would manufacture a revert of + // every doc-side edit older than the previous checkpoint, + // and merging it would delete those edits from the live + // document (the `--ui editor` ephemeral-editing clobber). + // With no disk delta there is nothing to merge; the + // checkpoint update below still advances the heads so the + // next sync doesn't re-fire. WriteBack is unaffected: its + // checkpoint always pairs heads with the content written + // back, so the fork-apply is a no-op when only the doc + // changed — but running it keeps the merged-content read + // below honest, so the gate is ReadOnly-specific. + let merge_fs_delta = !fs_unchanged || policy == DiskWritePolicy::WriteBack; + if merge_fs_delta { + // 3. Fork at sync checkpoint (with fallback if fork_at fails) + let mut forked = doc.fork_at(&last_sync_heads).unwrap_or_else(|e| { + warn!( + doc_id = %doc_id, + error = %e, + "fork_at failed, falling back to current state" + ); + doc.fork() + }); - // 5. Merge fork back into main document - doc.merge(&mut forked) - .map_err(|e| Error::Sync(format!("failed to merge fork: {:?}", e)))?; + // 4. Apply filesystem content to fork + let text_obj = forked + .get(ROOT, "text") + .map_err(|e| Error::Sync(format!("failed to get text object: {:?}", e)))? + .ok_or_else(|| { + Error::Sync(format!( + "document {} has no text field - was it initialized correctly?", + doc_id + )) + })? + .1; + + forked + .transact::<_, _, automerge::AutomergeError>(|tx| { + tx.update_text(&text_obj, &fs_content)?; + Ok(()) + }) + .map_err(|e| Error::Sync(format!("failed to update text in fork: {:?}", e)))?; + + // 5. Merge fork back into main document + doc.merge(&mut forked) + .map_err(|e| Error::Sync(format!("failed to merge fork: {:?}", e)))?; + } // 6. Read merged content and write back to filesystem let merged_text_obj = doc @@ -1865,6 +1891,58 @@ mod tests { ); } + #[tokio::test] + async fn test_sync_readonly_repeated_doc_edits_are_not_clobbered() { + // Repro for the `--ui editor --share` clobber (ephemeral editing + // under ReadOnly): with the disk untouched, successive periodic + // syncs must preserve ALL doc-side edits, not just the latest. + let temp = TempDir::new().unwrap(); + let repo = create_test_repo().await; + + let doc = create_doc_with_text("Original content"); + let handle = repo.create(doc).await.unwrap(); + let doc_id = handle.document_id().to_string(); + + let file_path = temp.path().join("test.qmd"); + std::fs::write(&file_path, "Original content").unwrap(); + + let mut sync_state = SyncState::load(temp.path()).unwrap(); + let heads = handle.with_document(|doc| doc.get_heads()); + sync_state.set_checkpoint(&doc_id, &heads, &sha256_hash("Original content")); + + // First browser edit; the first periodic sync absorbs it (disk + // untouched, doc keeps the change). + update_text_in_handle(&handle, "Original content\nedit-1"); + sync_document( + &handle, + &file_path, + &mut sync_state, + DiskWritePolicy::ReadOnly, + ) + .unwrap(); + assert_eq!(read_text_from_handle(&handle), "Original content\nedit-1"); + + // A second browser edit lands before the next periodic sync. The + // disk has NOT changed, so this sync must leave both edits intact. + update_text_in_handle(&handle, "Original content\nedit-1\nedit-2"); + sync_document( + &handle, + &file_path, + &mut sync_state, + DiskWritePolicy::ReadOnly, + ) + .unwrap(); + assert_eq!( + read_text_from_handle(&handle), + "Original content\nedit-1\nedit-2", + "the second periodic sync clobbered the first edit" + ); + assert_eq!( + std::fs::read_to_string(&file_path).unwrap(), + "Original content" + ); + } + #[tokio::test] async fn test_sync_readonly_filesystem_change_syncs_to_doc() { let temp = TempDir::new().unwrap(); From 9fb1adee79136d4dcddfb65e392840a7dbd11eca Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Sun, 9 Aug 2026 11:04:49 +0100 Subject: [PATCH 13/22] chore: sync quarto-p2p lockfile version with workspace 0.14.0 --- Cargo.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index c0b3b541d..4a16e22ba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5205,7 +5205,7 @@ dependencies = [ [[package]] name = "quarto-p2p" -version = "0.13.0" +version = "0.14.0" dependencies = [ "axum", "futures", From f66ad5aec985a8359dee89ff88ecaff6df560ae1 Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Sun, 9 Aug 2026 11:41:43 +0100 Subject: [PATCH 14/22] feat(preview): --browser opens the preview in a specific browser Host, --share, and --join all auto-opened the boot URL in the system default browser via open::that, with --no-browser as the only opt-out. --browser routes through open::with instead (open -a on macOS), so users pick a browser per invocation. clap makes it conflict with --no-browser; a failed named-browser open warns and stays non-fatal, same as the default path. --- crates/quarto/src/commands/preview.rs | 58 ++++++++++++++----- crates/quarto/src/main.rs | 44 ++++++++++++++ .../quarto/tests/integration/preview_cli.rs | 6 +- crates/wasm-quarto-hub-client/Cargo.lock | 34 +++++------ 4 files changed, 110 insertions(+), 32 deletions(-) diff --git a/crates/quarto/src/commands/preview.rs b/crates/quarto/src/commands/preview.rs index 75d8d8956..5230d38ac 100644 --- a/crates/quarto/src/commands/preview.rs +++ b/crates/quarto/src/commands/preview.rs @@ -34,6 +34,10 @@ pub struct PreviewArgs { pub host: Option, /// Skip the browser-open step. pub no_browser: bool, + /// Open the preview in this browser instead of the system + /// default (`--browser `). clap makes it conflict with + /// `--no-browser`, so the two never both reach here. + pub browser: Option, /// Override the ephemeral samod storage dir. Default: a fresh /// `tempfile::TempDir` that's deleted on shutdown. pub data_dir: Option, @@ -75,6 +79,9 @@ pub struct JoinArgs { pub host: Option, /// Skip the browser-open step. pub no_browser: bool, + /// Open the session in this browser instead of the system + /// default (`--browser `). + pub browser: Option, } pub fn execute_join(args: JoinArgs) -> Result<()> { @@ -194,6 +201,7 @@ async fn run(args: PreviewArgs) -> Result<()> { if !args.no_browser { let url_for_open = url.clone(); let host_for_open = host.clone(); + let browser_for_open = args.browser.clone(); tokio::spawn(async move { const READY_TIMEOUT: Duration = Duration::from_secs(10); if wait_until_accepting(&host_for_open, port, READY_TIMEOUT).await { @@ -211,7 +219,7 @@ async fn run(args: PreviewArgs) -> Result<()> { opening the browser anyway (it may need a manual reload)" ); } - open_browser_or_log(&url_for_open, false); + open_browser_or_log(&url_for_open, browser_for_open.as_deref(), false); }); } } @@ -287,6 +295,7 @@ async fn run(args: PreviewArgs) -> Result<()> { |n| n.to_string_lossy().into_owned(), ); let no_browser = args.no_browser; + let browser = args.browser.clone(); quarto_preview::run_with_on_ready(config, move |ctx| { let paths: Vec = ctx.index().get_all_files().into_keys().collect(); let url = match pick_editor_file(initial_page.as_deref(), &paths) { @@ -325,6 +334,7 @@ async fn run(args: PreviewArgs) -> Result<()> { println!(); if !no_browser { let host_for_open = host_for_ready.clone(); + let browser_for_open = browser.clone(); tokio::spawn(async move { const READY_TIMEOUT: Duration = Duration::from_secs(10); if !wait_until_accepting(&host_for_open, port, READY_TIMEOUT).await { @@ -337,7 +347,7 @@ async fn run(args: PreviewArgs) -> Result<()> { reload)" ); } - open_browser_or_log(&url, false); + open_browser_or_log(&url, browser_for_open.as_deref(), false); }); } }) @@ -425,7 +435,7 @@ async fn run_join(args: JoinArgs) -> Result<()> { println!(); if !args.no_browser { - open_browser_or_log(&url, false); + open_browser_or_log(&url, args.browser.as_deref(), false); } // Report status transitions ("connected via relay", "reconnecting…") @@ -616,18 +626,33 @@ fn validate_explicit_port(host: &str, port: u16) -> Result<()> { } } -/// Phase D.1: open the boot URL in the user's default browser unless -/// `--no-browser` was passed. Failure is logged + non-fatal — the -/// URL was already printed for copy-paste before this fires. -fn open_browser_or_log(url: &str, suppress: bool) { +/// Phase D.1: open the boot URL in the user's browser unless +/// `--no-browser` was passed. `browser` is the `--browser ` +/// value: `Some` opens that specific application (via `open::with`, +/// i.e. `open -a` on macOS), `None` the system default. Failure is +/// logged + non-fatal — the URL was already printed for copy-paste +/// before this fires. +fn open_browser_or_log(url: &str, browser: Option<&str>, suppress: bool) { if suppress { return; } - if let Err(e) = open::that(url) { - tracing::warn!( - error = %e, - "could not auto-open browser; the URL is printed above" - ); + let result = match browser { + Some(app) => open::with(url, app), + None => open::that(url), + }; + if let Err(e) = result { + if let Some(app) = browser { + tracing::warn!( + error = %e, + browser = app, + "could not open the preview in the requested browser; the URL is printed above" + ); + } else { + tracing::warn!( + error = %e, + "could not auto-open browser; the URL is printed above" + ); + } } } @@ -964,7 +989,14 @@ mod tests { // The `suppress` branch must return without touching the // OS — we never want a test run to fork a browser. Asserting // "doesn't panic, returns" is the contract. - open_browser_or_log("https://invalid.example.invalid/", true); + open_browser_or_log("https://invalid.example.invalid/", None, true); + // Suppression wins over an explicit --browser too: still no + // OS touch even though the browser name is bogus. + open_browser_or_log( + "https://invalid.example.invalid/", + Some("not-a-real-browser-q2-test"), + true, + ); } // ────────────────────────────────────────────────────────────── diff --git a/crates/quarto/src/main.rs b/crates/quarto/src/main.rs index e559fd9eb..d1fd9b219 100644 --- a/crates/quarto/src/main.rs +++ b/crates/quarto/src/main.rs @@ -217,6 +217,15 @@ enum Commands { #[arg(long)] no_browser: bool, + /// Open the preview in a specific browser instead of the + /// system default — e.g. `--browser firefox` or `--browser + /// "Google Chrome"`. The value is handed to the OS opener + /// (`open -a` on macOS), so use a command on PATH or an + /// installed application name. Incompatible with + /// --no-browser. + #[arg(long, value_name = "BROWSER", conflicts_with = "no_browser")] + browser: Option, + /// Override the directory the preview uses for ephemeral /// per-session state. Default: a fresh tempdir that is /// deleted when `q2 preview` exits. @@ -1001,6 +1010,38 @@ mod cli_parse_tests { assert_eq!(host.as_deref(), Some("127.0.0.1")); assert!(no_browser); } + + #[test] + fn preview_browser_parses_and_composes_with_share() { + let Commands::Preview { browser, share, .. } = + parse_preview(&["preview", "--share", "--browser", "firefox"]) + else { + unreachable!() + }; + assert!(share); + assert_eq!(browser.as_deref(), Some("firefox")); + } + + #[test] + fn preview_browser_composes_with_join() { + // Guests pick the browser for the local proxy URL too. + let Commands::Preview { join, browser, .. } = + parse_preview(&["preview", "--join", "q2previewabc", "--browser", "safari"]) + else { + unreachable!() + }; + assert_eq!(join.as_deref(), Some("q2previewabc")); + assert_eq!(browser.as_deref(), Some("safari")); + } + + #[test] + fn preview_browser_conflicts_with_no_browser() { + let err = match try_parse(&["preview", "--browser", "firefox", "--no-browser"]) { + Ok(_) => panic!("--browser and --no-browser must conflict"), + Err(e) => e, + }; + assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict); + } } fn main() -> Result<()> { @@ -1060,6 +1101,7 @@ fn main() -> Result<()> { port, host, no_browser, + browser, data_dir, preview_dir, no_project, @@ -1076,6 +1118,7 @@ fn main() -> Result<()> { port, host, no_browser, + browser, }) } else { commands::preview::execute(commands::preview::PreviewArgs { @@ -1083,6 +1126,7 @@ fn main() -> Result<()> { port, host, no_browser, + browser, data_dir, preview_dir, no_project, diff --git a/crates/quarto/tests/integration/preview_cli.rs b/crates/quarto/tests/integration/preview_cli.rs index 1fa0fae41..fed2b5dca 100644 --- a/crates/quarto/tests/integration/preview_cli.rs +++ b/crates/quarto/tests/integration/preview_cli.rs @@ -41,11 +41,13 @@ fn preview_help_advertises_phase_a_args() { let help = String::from_utf8(output.stdout).expect("help is UTF-8"); // Each Phase A flag — see claude-notes/plans/2026-05-13-q2-preview- - // phase-a.md §A.1. If one disappears or gets renamed, the user- - // facing contract changes; fail noisily. + // phase-a.md §A.1 — plus later additions like `--browser`. If one + // disappears or gets renamed, the user-facing contract changes; + // fail noisily. for flag in [ "--port", "--no-browser", + "--browser", "--data-dir", "--preview-dir", "--no-project", diff --git a/crates/wasm-quarto-hub-client/Cargo.lock b/crates/wasm-quarto-hub-client/Cargo.lock index 633c97c42..370b50ca2 100644 --- a/crates/wasm-quarto-hub-client/Cargo.lock +++ b/crates/wasm-quarto-hub-client/Cargo.lock @@ -377,7 +377,7 @@ dependencies = [ [[package]] name = "comrak-to-pandoc" -version = "0.13.0" +version = "0.14.0" dependencies = [ "comrak", "hashlink", @@ -2074,7 +2074,7 @@ dependencies = [ [[package]] name = "quarto-analysis" -version = "0.13.0" +version = "0.14.0" dependencies = [ "quarto-error-reporting", "quarto-pandoc-types", @@ -2096,7 +2096,7 @@ dependencies = [ [[package]] name = "quarto-brand" -version = "0.13.0" +version = "0.14.0" dependencies = [ "pathdiff", "quarto-util", @@ -2107,7 +2107,7 @@ dependencies = [ [[package]] name = "quarto-citeproc" -version = "0.13.0" +version = "0.14.0" dependencies = [ "glob", "hashlink", @@ -2138,7 +2138,7 @@ dependencies = [ [[package]] name = "quarto-core" -version = "0.13.0" +version = "0.14.0" dependencies = [ "anyhow", "async-trait", @@ -2190,7 +2190,7 @@ dependencies = [ [[package]] name = "quarto-csl" -version = "0.13.0" +version = "0.14.0" dependencies = [ "quarto-error-reporting", "quarto-source-map", @@ -2241,7 +2241,7 @@ dependencies = [ [[package]] name = "quarto-highlight" -version = "0.13.0" +version = "0.14.0" dependencies = [ "once_cell", "quarto-highlight-encoding", @@ -2267,7 +2267,7 @@ dependencies = [ [[package]] name = "quarto-highlight-encoding" -version = "0.13.0" +version = "0.14.0" dependencies = [ "serde", "serde_json", @@ -2275,7 +2275,7 @@ dependencies = [ [[package]] name = "quarto-lsp-core" -version = "0.13.0" +version = "0.14.0" dependencies = [ "pampa", "pollster", @@ -2296,7 +2296,7 @@ dependencies = [ [[package]] name = "quarto-navigation" -version = "0.13.0" +version = "0.14.0" dependencies = [ "quarto-config", "quarto-pandoc-types", @@ -2331,7 +2331,7 @@ dependencies = [ [[package]] name = "quarto-project-create" -version = "0.13.0" +version = "0.14.0" dependencies = [ "quarto-doctemplate", "serde", @@ -2341,7 +2341,7 @@ dependencies = [ [[package]] name = "quarto-sass" -version = "0.13.0" +version = "0.14.0" dependencies = [ "include_dir", "once_cell", @@ -2371,7 +2371,7 @@ dependencies = [ [[package]] name = "quarto-system-runtime" -version = "0.13.0" +version = "0.14.0" dependencies = [ "async-trait", "base64", @@ -2388,7 +2388,7 @@ dependencies = [ [[package]] name = "quarto-trace" -version = "0.13.0" +version = "0.14.0" dependencies = [ "flate2", "serde", @@ -2406,7 +2406,7 @@ dependencies = [ [[package]] name = "quarto-util" -version = "0.13.0" +version = "0.14.0" dependencies = [ "serde", "thiserror 2.0.18", @@ -2414,7 +2414,7 @@ dependencies = [ [[package]] name = "quarto-xml" -version = "0.13.0" +version = "0.14.0" dependencies = [ "quarto-error-reporting", "quarto-source-map", @@ -4021,7 +4021,7 @@ dependencies = [ [[package]] name = "wasm-printf-fmt" -version = "0.13.0" +version = "0.14.0" [[package]] name = "wasm-quarto-hub-client" From 4111452f6f778bf6e33c2429e2cfeaa334037c20 Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Sun, 9 Aug 2026 12:11:32 +0100 Subject: [PATCH 15/22] feat(preview): ephemeral-session banner in editor when --allow-edit is off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit q2 preview --ui editor without --allow-edit is an ephemeral sandbox: edits sync live to everyone connected but are never written to disk. The only notice was one line printed at CLI startup — easy to miss while working in the editor, and lost edits were a silent surprise. The hub-client now fetches /api/preview/config once at boot (bd-ov4gqk3m) and, when allowEdit is false, shows a persistent banner under the editor header. The endpoint exists only on preview servers (a --join guest's TCP proxy splices through to the host, so guests see the banner too); a standalone hub 404s or answers with SPA-fallback HTML, both treated as "not a preview", so the banner never appears against a real hub. --- hub-client/src/App.tsx | 10 ++ hub-client/src/components/Editor.css | 12 +++ hub-client/src/components/Editor.tsx | 18 +++- .../src/hooks/usePreviewSession.test.tsx | 57 ++++++++++ hub-client/src/hooks/usePreviewSession.ts | 29 +++++ hub-client/src/services/previewConfig.test.ts | 101 ++++++++++++++++++ hub-client/src/services/previewConfig.ts | 44 ++++++++ 7 files changed, 270 insertions(+), 1 deletion(-) create mode 100644 hub-client/src/hooks/usePreviewSession.test.tsx create mode 100644 hub-client/src/hooks/usePreviewSession.ts create mode 100644 hub-client/src/services/previewConfig.test.ts create mode 100644 hub-client/src/services/previewConfig.ts diff --git a/hub-client/src/App.tsx b/hub-client/src/App.tsx index 48f3257d5..38cacad29 100644 --- a/hub-client/src/App.tsx +++ b/hub-client/src/App.tsx @@ -42,6 +42,7 @@ import { useAuth } from './hooks/useAuth'; import { useAuthProbe } from './hooks/useAuthProbe'; import { useSessionKeepAlive } from './hooks/useSessionKeepAlive'; import { useExecutionChannel } from './hooks/useExecutionChannel'; +import { usePreviewSession } from './hooks/usePreviewSession'; import { resolveActorId as resolveActorIdRequest } from './services/authService'; import type { Route, ShareRoute, LinkProjectSetRoute } from './utils/routing'; import { resolveSyncServerUrl, DEFAULT_SYNC_SERVER, parseHashRoute } from './utils/routing'; @@ -198,6 +199,14 @@ function App() { return bootRoute.type === 'share' && bootRoute.ephemeral === true; }); + // `q2 preview` session config (bd-ov4gqk3m): when the serving server + // is a preview started without --allow-edit, the editor shows an + // ephemeral-session banner. Null on a standalone hub (no such + // endpoint), which never shows the banner. Unlike the boot-URL flag + // above this survives reloads and works for --join guests, whose + // proxy splices every connection through to the host. + const previewSession = usePreviewSession(); + // Load screen name from IndexedDB (for identity mapping in Automerge docs). // When auth is enabled, wait for it to resolve so we can upgrade anonymous // names to the OIDC display name on first login. Without auth, load immediately. @@ -889,6 +898,7 @@ function App() { executorsOnline={liveExecutors.length > 0} onRequestExecution={requestExecution} isOnline={isOnline} + sessionEphemeral={previewSession?.allowEdit === false} /> diff --git a/hub-client/src/components/Editor.css b/hub-client/src/components/Editor.css index 01d4acb79..07ae278b3 100644 --- a/hub-client/src/components/Editor.css +++ b/hub-client/src/components/Editor.css @@ -409,6 +409,18 @@ flex-shrink: 0; } +/* Ephemeral-session banner — `q2 preview --ui editor` without --allow-edit */ +.ephemeral-session-banner { + background: var(--warning-bg); + color: var(--warning-text); + border-bottom: 1px solid var(--warning-border); + padding: 4px 16px; + font-size: 12px; + text-align: center; + flex-shrink: 0; + cursor: help; +} + /* Replay mode banner — overlays bottom edge of header */ .replay-mode-banner { position: absolute; diff --git a/hub-client/src/components/Editor.tsx b/hub-client/src/components/Editor.tsx index 8aa87afef..24e5e85a8 100644 --- a/hub-client/src/components/Editor.tsx +++ b/hub-client/src/components/Editor.tsx @@ -77,6 +77,12 @@ interface Props { onRequestExecution?: (path: string) => string | null; /** Whether the project is connected to the sync server */ isOnline: boolean; + /** + * `q2 preview --ui editor` without `--allow-edit` (bd-ov4gqk3m): edits + * sync live to everyone connected but are never written to disk. + * Drives the ephemeral-session banner. Absent/false on a real hub. + */ + sessionEphemeral?: boolean; } // Map file extension to Monaco language ID @@ -165,7 +171,7 @@ function selectDefaultFile(files: FileEntry[]): FileEntry | null { return files[0]; } -export default function Editor({ project, files, fileContents, onDisconnect, onContentOperations, route, onNavigateToFile, identities, captures, executorsOnline, onRequestExecution, isOnline }: Props) { +export default function Editor({ project, files, fileContents, onDisconnect, onContentOperations, route, onNavigateToFile, identities, captures, executorsOnline, onRequestExecution, isOnline, sessionEphemeral }: Props) { // View mode for pane sizing const { viewMode } = useViewMode(); const { effectiveTheme } = useTheme(); @@ -993,6 +999,16 @@ export default function Editor({ project, files, fileContents, onDisconnect, onC
)} + {!isFullscreenPreview && sessionEphemeral && ( +
+ Ephemeral session — edits won't be saved to disk +
+ )} + {!isFullscreenPreview && unlocatedErrors.length > 0 && (
{unlocatedErrors.map((diag, i) => ( diff --git a/hub-client/src/hooks/usePreviewSession.test.tsx b/hub-client/src/hooks/usePreviewSession.test.tsx new file mode 100644 index 000000000..b135f9d90 --- /dev/null +++ b/hub-client/src/hooks/usePreviewSession.test.tsx @@ -0,0 +1,57 @@ +/** + * Unit tests for usePreviewSession. The hook is a thin boot-time + * wrapper over fetchPreviewSessionConfig: null until the fetch + * resolves, and null forever when the serving server is not a + * `q2 preview` session (callers gate on an explicit value, so null is + * always the banner-free case). + * + * @vitest-environment jsdom + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, act, waitFor } from '@testing-library/react'; + +vi.mock('../services/previewConfig', () => ({ + fetchPreviewSessionConfig: vi.fn(), +})); + +import { usePreviewSession } from './usePreviewSession'; +import { fetchPreviewSessionConfig } from '../services/previewConfig'; + +const mockFetchConfig = vi.mocked(fetchPreviewSessionConfig); + +describe('usePreviewSession', () => { + beforeEach(() => { + mockFetchConfig.mockReset(); + }); + + it('starts null and resolves to the session config', async () => { + mockFetchConfig.mockResolvedValue({ allowEdit: false }); + + const { result } = renderHook(() => usePreviewSession()); + expect(result.current).toBeNull(); + + await waitFor(() => expect(result.current).toEqual({ allowEdit: false })); + }); + + it('stays null when the server is not a preview session', async () => { + mockFetchConfig.mockResolvedValue(null); + + const { result } = renderHook(() => usePreviewSession()); + await waitFor(() => expect(mockFetchConfig).toHaveBeenCalled()); + // Flush the resolved promise's .then callback. + await act(async () => {}); + + expect(result.current).toBeNull(); + }); + + it('fetches once at boot and does not refetch on rerender', async () => { + mockFetchConfig.mockResolvedValue({ allowEdit: true }); + + const { rerender } = renderHook(() => usePreviewSession()); + await waitFor(() => expect(mockFetchConfig).toHaveBeenCalledTimes(1)); + + rerender(); + expect(mockFetchConfig).toHaveBeenCalledTimes(1); + }); +}); diff --git a/hub-client/src/hooks/usePreviewSession.ts b/hub-client/src/hooks/usePreviewSession.ts new file mode 100644 index 000000000..8f5b45151 --- /dev/null +++ b/hub-client/src/hooks/usePreviewSession.ts @@ -0,0 +1,29 @@ +/** + * usePreviewSession — the serving server's `q2 preview` session config. + * + * Fetched once at boot: the values mirror the CLI flags the server was + * started with and are fixed for its lifetime, so there is no polling. + * Returns null while loading and whenever the server is not a + * `q2 preview` session (standalone hub, vite dev) — callers gate UI on + * an explicit value (e.g. `config?.allowEdit === false`), so null is + * always the safe, banner-free case. + */ + +import { useEffect, useState } from 'react'; +import { fetchPreviewSessionConfig, type PreviewSessionConfig } from '../services/previewConfig'; + +export function usePreviewSession(): PreviewSessionConfig | null { + const [config, setConfig] = useState(null); + + useEffect(() => { + let cancelled = false; + void fetchPreviewSessionConfig().then((fetched) => { + if (!cancelled && fetched) setConfig(fetched); + }); + return () => { + cancelled = true; + }; + }, []); + + return config; +} diff --git a/hub-client/src/services/previewConfig.test.ts b/hub-client/src/services/previewConfig.test.ts new file mode 100644 index 000000000..0353ac7e4 --- /dev/null +++ b/hub-client/src/services/previewConfig.test.ts @@ -0,0 +1,101 @@ +/** + * Unit tests for previewConfig. + * + * fetchPreviewSessionConfig decides whether the serving server is a + * `q2 preview` session and, if so, whether edits persist to disk + * (`allowEdit`). Null is the safe default: standalone hubs (no such + * route), SPA-fallback HTML, malformed bodies, and network errors must + * all yield null so the editor never shows the ephemeral-session + * banner against a real hub. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +import { fetchPreviewSessionConfig } from './previewConfig'; + +function jsonResponse(body: unknown, init?: { ok?: boolean; status?: number }): Response { + return { + ok: init?.ok ?? true, + status: init?.status ?? 200, + json: () => Promise.resolve(body), + } as Response; +} + +describe('fetchPreviewSessionConfig', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal('fetch', vi.fn()); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + }); + + it('returns the config when the server is a preview session', async () => { + vi.mocked(fetch).mockResolvedValue(jsonResponse({ allowEdit: false })); + + await expect(fetchPreviewSessionConfig()).resolves.toEqual({ allowEdit: false }); + expect(fetch).toHaveBeenCalledWith('/api/preview/config', { + credentials: 'same-origin', + }); + }); + + it('passes allowEdit: true through (persistent session)', async () => { + vi.mocked(fetch).mockResolvedValue(jsonResponse({ allowEdit: true })); + + await expect(fetchPreviewSessionConfig()).resolves.toEqual({ allowEdit: true }); + }); + + it('ignores extra fields such as editorBoot', async () => { + vi.mocked(fetch).mockResolvedValue( + jsonResponse({ + allowEdit: false, + editorBoot: { indexDocId: 'doc', file: 'index.qmd', name: 'proj' }, + }), + ); + + await expect(fetchPreviewSessionConfig()).resolves.toEqual({ allowEdit: false }); + }); + + it('returns null on 404 (a standalone hub has no such route)', async () => { + vi.mocked(fetch).mockResolvedValue(jsonResponse({}, { ok: false, status: 404 })); + + await expect(fetchPreviewSessionConfig()).resolves.toBeNull(); + }); + + it('returns null when the body is SPA-fallback HTML (json() throws)', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.reject(new SyntaxError('Unexpected token <')), + } as unknown as Response); + + await expect(fetchPreviewSessionConfig()).resolves.toBeNull(); + }); + + it.each([{}, { allowEdit: 'false' }, { allowEdit: 0 }, null, 'allowEdit'])( + 'returns null when allowEdit is missing or not a boolean: %j', + async (body) => { + vi.mocked(fetch).mockResolvedValue(jsonResponse(body)); + + await expect(fetchPreviewSessionConfig()).resolves.toBeNull(); + }, + ); + + it('returns null on network error', async () => { + vi.mocked(fetch).mockRejectedValue(new TypeError('fetch failed')); + + await expect(fetchPreviewSessionConfig()).resolves.toBeNull(); + }); + + it('prefixes the path with VITE_HUB_BASE_PATH when set', async () => { + vi.stubEnv('VITE_HUB_BASE_PATH', '/subpath'); + vi.mocked(fetch).mockResolvedValue(jsonResponse({ allowEdit: true })); + + await fetchPreviewSessionConfig(); + expect(fetch).toHaveBeenCalledWith('/subpath/api/preview/config', { + credentials: 'same-origin', + }); + }); +}); diff --git a/hub-client/src/services/previewConfig.ts b/hub-client/src/services/previewConfig.ts new file mode 100644 index 000000000..01cf74091 --- /dev/null +++ b/hub-client/src/services/previewConfig.ts @@ -0,0 +1,44 @@ +/** + * Preview Session Config + * + * `q2 preview` serves `GET /api/preview/config` with session-level + * settings (bd-ov4gqk3m) — currently `allowEdit`, which mirrors the + * CLI's `--allow-edit` flag: whether edits made in the UI are written + * back to the host's files on disk. Without `--allow-edit` the session + * is an ephemeral sandbox: edits sync live to everyone connected but + * are never persisted. + * + * The endpoint exists only on the per-session preview server (a `--join` + * guest's local TCP proxy splices every connection through to the + * host, so guests read the host's value). A standalone hub has no such + * route — it 404s or answers with the SPA fallback — and both outcomes + * are treated here as "not a preview session" (null), so the editor + * shows no ephemeral-session UI against a real hub. + */ + +import { hubPath } from '../utils/routing'; + +export interface PreviewSessionConfig { + /** Mirrors the host's `--allow-edit`: edits persist to disk. */ + allowEdit: boolean; +} + +/** + * Fetch the preview session config, or null when the serving server is + * not a `q2 preview` session (standalone hub, dev server) or the fetch + * fails. Only a response carrying an explicit boolean `allowEdit` + * counts — SPA-fallback HTML and older servers both yield null. + */ +export async function fetchPreviewSessionConfig(): Promise { + try { + const res = await fetch(hubPath('/api/preview/config'), { credentials: 'same-origin' }); + if (!res.ok) return null; + const data: unknown = await res.json(); + if (typeof data !== 'object' || data === null) return null; + const { allowEdit } = data as { allowEdit?: unknown }; + if (typeof allowEdit !== 'boolean') return null; + return { allowEdit }; + } catch { + return null; + } +} From e2b7db352043f2f20a674c75d6752fae5276273d Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Sun, 9 Aug 2026 13:43:16 +0100 Subject: [PATCH 16/22] =?UTF-8?q?refactor(preview):=20live-share=20review?= =?UTF-8?q?=20follow-ups=20=E2=80=94=20startup=20overlap,=20file=20pick,?= =?UTF-8?q?=20dedupe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - --share no longer blocks the host's own preview on the tunnel's relay wait (up to ~10 s when no relay is reachable): the share task is gated on the server's on_ready via a watch channel instead of being awaited before the listener binds (bd-jhvkwosw). The join banner now prints after the boot URL in both UI modes, keeping the join line last on the terminal. A tunnel start failure degrades to a stderr warning rather than failing the preview, and Ctrl-C during the relay wait aborts the parked task instead of hanging exit. New integration tests pin the gate (no banner/tunnel before ready; quiet exit when the server dies first). - pick_editor_file prefers a root index.qmd over the sorted-first fallback (bd-jt1etjbn follow-up); verified end to end through `q2 preview --ui editor` (boot URL now carries file=index.qmd). - build.rs embed dedupe cheap-rejects on metadata/size before reading files — non-matches no longer slurp both copies (~76 MB of reads for the shared WASM). Embed output verified byte-identical pre/post. - Extract spawn_browser_open_when_ready shared by both UI modes, drop the dead suppress param from open_browser_or_log, and consolidate the duplicated share-route doc contract onto editor_share_route. - Recast the quarto-p2p tunnel-client example as a debugging driver now that `q2 preview --join` is the real guest. Verified: cargo xtask verify --skip-hub-build (all 14 steps); e2e `q2 preview --share` serves /health 0.4 s before the banner prints and exits cleanly on SIGINT. --- crates/quarto-p2p/examples/tunnel-client.rs | 8 +- crates/quarto-preview/Cargo.toml | 2 +- crates/quarto-preview/build.rs | 23 ++- crates/quarto-preview/src/lib.rs | 81 +++++--- crates/quarto-preview/src/share.rs | 45 ++++ .../quarto-preview/tests/integration/share.rs | 111 +++++++++- crates/quarto/src/commands/preview.rs | 195 ++++++++---------- 7 files changed, 329 insertions(+), 136 deletions(-) diff --git a/crates/quarto-p2p/examples/tunnel-client.rs b/crates/quarto-p2p/examples/tunnel-client.rs index f8855c51d..8ed573a3d 100644 --- a/crates/quarto-p2p/examples/tunnel-client.rs +++ b/crates/quarto-p2p/examples/tunnel-client.rs @@ -1,8 +1,10 @@ //! Minimal guest-side driver for `q2 preview --share` sessions. //! -//! Until `q2 preview --join` lands (live-share plan Phase 3, -//! bd-6y0p1bne), this example is the reference guest: it joins a shared -//! preview session and serves it on a local loopback port. +//! `q2 preview --join ` (live-share plan Phase 3, bd-6y0p1bne) +//! is the real guest — it adds health gating, editor boot URLs, browser +//! open, and terminal rejection handling. This example stays as a bare +//! debugging driver: join a session, serve it on a local loopback port, +//! and print raw tunnel status transitions. //! //! ```text //! cargo run -p quarto-p2p --example tunnel-client -- [local-port] diff --git a/crates/quarto-preview/Cargo.toml b/crates/quarto-preview/Cargo.toml index 0e3b1dcf5..8bd787ae3 100644 --- a/crates/quarto-preview/Cargo.toml +++ b/crates/quarto-preview/Cargo.toml @@ -10,7 +10,7 @@ description = "Local HTTP server hosting the q2-preview SPA. Wraps quarto-hub fo anyhow.workspace = true tracing.workspace = true include_dir.workspace = true -tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "signal", "fs"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "signal", "fs", "sync"] } axum = "0.8" flate2.workspace = true pollster.workspace = true diff --git a/crates/quarto-preview/build.rs b/crates/quarto-preview/build.rs index c9d23ed88..37330cf65 100644 --- a/crates/quarto-preview/build.rs +++ b/crates/quarto-preview/build.rs @@ -116,12 +116,12 @@ fn copy_filtered(root: &Path, dir: &Path, embed: &Path, dedupe_against: Option<& copy_filtered(root, &path, embed, dedupe_against); continue; } - let bytes = std::fs::read(&path).expect("read editor dist file"); if let Some(viewer) = dedupe_against - && std::fs::read(viewer.join(rel)).is_ok_and(|v| v == bytes) + && files_byte_identical(&path, &viewer.join(rel)) { continue; // shared with the viewer embed; served from there } + let bytes = std::fs::read(&path).expect("read editor dist file"); let dest = embed.join(rel); if let Some(parent) = dest.parent() { std::fs::create_dir_all(parent).expect("create embed subdir"); @@ -130,6 +130,25 @@ fn copy_filtered(root: &Path, dir: &Path, embed: &Path, dedupe_against: Option<& } } +/// Byte-equality check with a metadata cheap-reject: a missing viewer +/// counterpart or a size mismatch means "not identical" without reading +/// either file. Only same-size pairs pay for the full read-compare — +/// previously every editor-dist file slurped its viewer counterpart +/// (and itself, before the comparison was even possible), including +/// the ~38 MB WASM, whether or not the bytes could ever match. +fn files_byte_identical(a: &Path, b: &Path) -> bool { + let (Ok(a_meta), Ok(b_meta)) = (std::fs::metadata(a), std::fs::metadata(b)) else { + return false; + }; + if a_meta.len() != b_meta.len() { + return false; + } + match (std::fs::read(a), std::fs::read(b)) { + (Ok(a_bytes), Ok(b_bytes)) => a_bytes == b_bytes, + _ => false, + } +} + fn make_placeholder_dist() -> PathBuf { let out_dir = PathBuf::from(std::env::var("OUT_DIR").unwrap()); let dist = out_dir.join("placeholder-dist"); diff --git a/crates/quarto-preview/src/lib.rs b/crates/quarto-preview/src/lib.rs index a5f911e6c..c04cd5972 100644 --- a/crates/quarto-preview/src/lib.rs +++ b/crates/quarto-preview/src/lib.rs @@ -192,12 +192,15 @@ pub struct PreviewConfig { /// written back to the user's files. pub allow_edit: bool, /// Share this preview session over an end-to-end encrypted iroh - /// tunnel (`--share`, bd-jhvkwosw). When set, [`run`] spawns a - /// [`share::ShareSession`] targeting `host:port` before the server - /// starts and prints the join banner; the tunnel is shut down after - /// the server exits (before the CLI drops its ephemeral `TempDir`). - /// Requires a pre-resolved (non-zero) `port` — the CLI probes one - /// before calling in. + /// tunnel (`--share`, bd-jhvkwosw). When set, [`run`] starts a + /// [`share::ShareSession`] targeting `host:port` on a background + /// task once the server reaches `on_ready` — the tunnel's relay + /// wait never delays the host's own preview — and prints the join + /// banner when the tunnel is up. A tunnel start failure is + /// reported on stderr but does not fail the preview. The tunnel is + /// shut down after the server exits (before the CLI drops its + /// ephemeral `TempDir`). Requires a pre-resolved (non-zero) `port` + /// — the CLI probes one before calling in. pub share: bool, /// Which embedded frontend to serve (`--ui`, Phase 4 bd-jt1etjbn): /// the read-only preview SPA (default) or the full hub-client @@ -289,6 +292,12 @@ where .cache_dir .clone() .unwrap_or_else(|| config.data_dir.join("captures")); + // Live-share gate (bd-jhvkwosw): the share task below starts its + // tunnel only once the server reaches `on_ready`, so the production + // preset's relay wait never delays the host's own preview, and the + // join banner prints after the boot URL in every UI mode. + let (share_ready_tx, share_ready_rx) = tokio::sync::watch::channel(false); + let registry_for_on_ready = engine_registry.clone(); let cache_dir_for_on_ready = cache_dir.clone(); let project_root_for_scripts = config.project_root.clone(); @@ -322,6 +331,10 @@ where // Fire the extra hook after the driver is enqueued so callers // can rely on it being either in-flight or already done. extra_on_ready(ctx); + // Unblock the live-share tunnel task (a no-op send when not + // sharing). After `extra_on_ready` so the CLI's editor-mode + // boot URL prints before the join banner. + let _ = share_ready_tx.send(true); }); // Phase C.2 hook: after sync_file updates samod with the new @@ -368,27 +381,28 @@ where }); }); - // bd-jhvkwosw (live-share Phase 2): when sharing, spawn the tunnel - // host *before* the server starts — the ticket's inputs (host, port, - // token, endpoint addr) all exist already, and printing ahead of the - // listener bind matches the CLI boot-URL print's contract (a - // too-fast guest just retries via its health supervisor). - let share_session = if config.share { + // bd-jhvkwosw (live-share Phase 2): when sharing, the tunnel runs + // on a background task gated on the server's `on_ready` (signaled + // in the callback above) rather than started inline here. The + // ticket's inputs (host, port, token, endpoint addr) all exist + // already, but the production preset's relay wait can run to ~10 s + // when no relay is reachable — too long to keep the host's own + // preview from binding and serving. A tunnel start failure is + // reported on stderr by the task and no longer fails the preview. + let share_task = if config.share { anyhow::ensure!( config.port != 0, "--share requires a resolved port; the CLI probes a free one before starting \ the server, library callers must do the same" ); - let session = share::start_share_session( + Some(share::spawn_share_task( quarto_p2p::TunnelHostConfig::default(), - &config.host, + config.host.clone(), config.port, config.allow_edit, + share_ready_rx, |banner| println!("\n{banner}\n"), - ) - .await - .context("starting the live-share tunnel")?; - Some(session) + )) } else { None }; @@ -403,13 +417,30 @@ where .await; // bd-jhvkwosw: tunnel teardown joins the graceful-shutdown path — - // after the server (and its final filesystem sync) exits, before the - // CLI drops its ephemeral TempDir. Failure is logged, not fatal; - // the process is exiting either way. - if let Some(session) = share_session - && let Err(e) = session.shutdown().await - { - tracing::warn!(error = %e, "live-share tunnel shutdown failed"); + // after the server (and its final filesystem sync) exits, before + // the CLI drops its ephemeral TempDir. In normal operation the task + // finished long ago (it completes once the tunnel is up) and the + // session shuts down gracefully here; a task still parked on the + // gate or in its relay wait is aborted instead of awaited, so a + // Ctrl-C during startup doesn't hang on the ~10 s timeouts. + // Failure is logged, not fatal; the process is exiting either way. + if let Some(task) = share_task { + if task.is_finished() { + match task.await { + Ok(Some(session)) => { + if let Err(e) = session.shutdown().await { + tracing::warn!(error = %e, "live-share tunnel shutdown failed"); + } + } + // The gate never fired (the server failed before + // on_ready) or the tunnel start failed (already + // reported on stderr by the task). + Ok(None) => {} + Err(e) => tracing::warn!(error = %e, "live-share tunnel task failed"), + } + } else { + task.abort(); + } } server_result.context("quarto-hub server failed")?; diff --git a/crates/quarto-preview/src/share.rs b/crates/quarto-preview/src/share.rs index 1ffb2c6e6..d081c3107 100644 --- a/crates/quarto-preview/src/share.rs +++ b/crates/quarto-preview/src/share.rs @@ -10,6 +10,8 @@ use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; use quarto_p2p::{PreviewShareTicket, TunnelError, TunnelHost, TunnelHostConfig, TunnelHostHandle}; +use tokio::sync::watch; +use tokio::task::JoinHandle; /// A live share session: the join ticket plus the running tunnel host. pub struct ShareSession { @@ -53,6 +55,49 @@ pub async fn start_share_session( Ok(ShareSession { ticket, handle }) } +/// Spawn the share session on a background task, starting the tunnel +/// only once `ready` flips to `true` (wired to the server's `on_ready`). +/// +/// The gate exists for two reasons: +/// +/// - The production preset's relay wait (up to ~10 s when no relay is +/// reachable) runs on this task, so the host's own preview server +/// binds and serves immediately instead of waiting for the tunnel. +/// - The banner prints after the server's boot URL in every UI mode, +/// keeping the join line the last thing on the terminal (the +/// copy-paste contract [`format_share_banner`] documents). +/// +/// A start failure is reported on stderr and yields `None` — the local +/// preview keeps serving without sharing rather than failing outright. +/// A `ready` channel that closes without ever flipping (the server +/// died before `on_ready`) also yields `None`, quietly. +pub fn spawn_share_task( + cfg: TunnelHostConfig, + host: String, + port: u16, + allow_edit: bool, + mut ready: watch::Receiver, + announce: impl FnOnce(&str) + Send + 'static, +) -> JoinHandle> { + tokio::spawn(async move { + // Park until the server readies; a closed gate (server died + // before on_ready) means there is nothing to share. + if ready.wait_for(|&r| r).await.is_err() { + return None; + } + match start_share_session(cfg, &host, port, allow_edit, announce).await { + Ok(session) => Some(session), + Err(e) => { + eprintln!( + "\n could not start the live-share tunnel: {e}\n \ + the local preview continues without sharing.\n" + ); + None + } + } + }) +} + /// Render the share banner: capability warning + the bare /// `q2 preview --join …` line (last, with nothing after it, so a /// triple-click / drag copy survives terminal wrapping). diff --git a/crates/quarto-preview/tests/integration/share.rs b/crates/quarto-preview/tests/integration/share.rs index 4edf1aa1d..c283b9a5d 100644 --- a/crates/quarto-preview/tests/integration/share.rs +++ b/crates/quarto-preview/tests/integration/share.rs @@ -7,7 +7,7 @@ use std::time::Duration; use quarto_p2p::{EndpointPreset, TunnelClient, TunnelClientConfig, TunnelHostConfig}; -use quarto_preview::share::{format_share_banner, start_share_session}; +use quarto_preview::share::{format_share_banner, spawn_share_task, start_share_session}; /// Generous cap for individual awaits so a broken tunnel fails the test /// instead of hanging it. @@ -113,6 +113,115 @@ async fn share_glue_tunnels_to_preview_port_and_announces_join_string() { session.shutdown().await.expect("session shutdown"); } +/// The share task is gated on the server's ready signal: the tunnel's +/// relay wait must never delay server startup, and the banner must +/// print after the boot URL. Nothing may start before the gate fires — +/// with the hermetic preset an ungated spawn would finish in +/// milliseconds, so an early banner proves the gate is broken. +#[tokio::test] +async fn share_task_waits_for_server_ready_gate() { + // Stand-in for the preview hub, as in the glue test above. + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind stand-in preview server"); + let port = listener.local_addr().expect("local_addr").port(); + let app = axum::Router::new().route("/health", axum::routing::get(|| async { "GATE-MARKER" })); + tokio::spawn(async move { + axum::serve(listener, app).await.expect("axum serve"); + }); + + let announced = std::sync::Arc::new(std::sync::Mutex::new(Vec::::new())); + let (ready_tx, ready_rx) = tokio::sync::watch::channel(false); + let task = { + let announced = announced.clone(); + spawn_share_task( + hermetic_host_cfg(), + "127.0.0.1".to_string(), + port, + false, + ready_rx, + move |banner| announced.lock().unwrap().push(banner.to_string()), + ) + }; + + // Before the gate: no banner, no finished task. + tokio::time::sleep(Duration::from_millis(300)).await; + assert!( + announced.lock().unwrap().is_empty(), + "the banner must wait for the server-ready gate" + ); + assert!( + !task.is_finished(), + "the share task must park on the ready gate" + ); + + // Fire the gate: the tunnel starts and the banner prints. + ready_tx.send(true).expect("send ready"); + let session = tokio::time::timeout(STEP_TIMEOUT, task) + .await + .expect("share task should finish once gated") + .expect("share task panicked") + .expect("tunnel starts once the gate fires"); + assert_eq!(announced.lock().unwrap().len(), 1, "banner printed once"); + + // And the tunnel actually works. + let (local, client) = tokio::time::timeout( + STEP_TIMEOUT, + TunnelClient::bind( + TunnelClientConfig { + preset: EndpointPreset::HermeticLoopback, + }, + session.ticket.clone(), + "127.0.0.1:0".parse().unwrap(), + ), + ) + .await + .expect("client bind should not hang") + .expect("tunnel client binds"); + let body = tokio::time::timeout(STEP_TIMEOUT, reqwest::get(format!("http://{local}/health"))) + .await + .expect("GET through tunnel should not hang") + .expect("GET through tunnel succeeds") + .text() + .await + .expect("response body"); + assert_eq!(body, "GATE-MARKER"); + + client.shutdown().await.expect("client shutdown"); + session.shutdown().await.expect("session shutdown"); +} + +/// A server that dies before `on_ready` (the gate drops without ever +/// firing) must end the share task quietly: no banner, no session, no +/// lingering task for the caller to reap. +#[tokio::test] +async fn share_task_exits_quietly_when_server_dies_before_ready() { + let announced = std::sync::Arc::new(std::sync::Mutex::new(Vec::::new())); + let (ready_tx, ready_rx) = tokio::sync::watch::channel(false); + let task = { + let announced = announced.clone(); + spawn_share_task( + hermetic_host_cfg(), + "127.0.0.1".to_string(), + 1, // never dialed: the gate drops before the tunnel starts + false, + ready_rx, + move |banner| announced.lock().unwrap().push(banner.to_string()), + ) + }; + + drop(ready_tx); + let out = tokio::time::timeout(STEP_TIMEOUT, task) + .await + .expect("the share task must end when the gate drops") + .expect("share task panicked"); + assert!(out.is_none(), "no session when the server never readied"); + assert!( + announced.lock().unwrap().is_empty(), + "no banner when the server never readied" + ); +} + /// Banner wording: what the token grants must be printed at share time /// (security model). Read-only sessions must not claim edit capability; /// `--allow-edit` sessions must warn about disk writes. diff --git a/crates/quarto/src/commands/preview.rs b/crates/quarto/src/commands/preview.rs index 5230d38ac..335b6cddd 100644 --- a/crates/quarto/src/commands/preview.rs +++ b/crates/quarto/src/commands/preview.rs @@ -183,44 +183,13 @@ async fn run(args: PreviewArgs) -> Result<()> { println!(" → {url}"); println!(); - // Phase D.1 (bd-kw93.8): actually open a browser tab. Failure to - // open is logged + non-fatal (the URL is already printed for - // copy-paste). Suppressed by --no-browser. - // - // bd-a6dvrdg1: open only once the server is actually accepting - // connections. The server doesn't start until `quarto_preview::run` - // below (which then blocks until shutdown), so the open has to run - // on a spawned task that waits for readiness while the main task - // goes on to start the server. Opening eagerly here — as we used to - // — raced the server's startup: on larger projects the browser - // connected before `axum::serve` was live and showed "Unable to - // connect" until a manual reload. The probe (`wait_until_accepting`) - // closes that race by gating on the real accept condition. The port - // is the one we pre-probed; nothing is listening on it until the - // server binds, so the probe naturally retries across the gap. + // Phase D.1 (bd-kw93.8): actually open a browser tab, gated on + // the server accepting connections (bd-a6dvrdg1 — see + // `spawn_browser_open_when_ready`). Failure to open is logged + + // non-fatal (the URL is already printed for copy-paste). + // Suppressed by --no-browser. if !args.no_browser { - let url_for_open = url.clone(); - let host_for_open = host.clone(); - let browser_for_open = args.browser.clone(); - tokio::spawn(async move { - const READY_TIMEOUT: Duration = Duration::from_secs(10); - if wait_until_accepting(&host_for_open, port, READY_TIMEOUT).await { - info!(host = %host_for_open, port, "preview server accepting connections; opening browser"); - } else { - // We still consider a >10s startup a bug; opening anyway - // (rather than never) preserves the old behavior as a - // floor, and the warning gives the slow start visibility - // instead of leaving it silent. - tracing::warn!( - host = %host_for_open, - port, - timeout_secs = READY_TIMEOUT.as_secs(), - "preview server has not accepted a connection within the timeout; \ - opening the browser anyway (it may need a manual reload)" - ); - } - open_browser_or_log(&url_for_open, browser_for_open.as_deref(), false); - }); + spawn_browser_open_when_ready(host.clone(), port, url, args.browser.clone()); } } quarto_preview::PreviewUi::Editor => { @@ -333,22 +302,12 @@ async fn run(args: PreviewArgs) -> Result<()> { println!(" → {url}"); println!(); if !no_browser { - let host_for_open = host_for_ready.clone(); - let browser_for_open = browser.clone(); - tokio::spawn(async move { - const READY_TIMEOUT: Duration = Duration::from_secs(10); - if !wait_until_accepting(&host_for_open, port, READY_TIMEOUT).await { - tracing::warn!( - host = %host_for_open, - port, - timeout_secs = READY_TIMEOUT.as_secs(), - "preview server has not accepted a connection within the \ - timeout; opening the browser anyway (it may need a manual \ - reload)" - ); - } - open_browser_or_log(&url, browser_for_open.as_deref(), false); - }); + spawn_browser_open_when_ready( + host_for_ready.clone(), + port, + url, + browser.clone(), + ); } }) .await @@ -435,7 +394,7 @@ async fn run_join(args: JoinArgs) -> Result<()> { println!(); if !args.no_browser { - open_browser_or_log(&url, args.browser.as_deref(), false); + open_browser_or_log(&url, args.browser.as_deref()); } // Report status transitions ("connected via relay", "reconnecting…") @@ -626,16 +585,13 @@ fn validate_explicit_port(host: &str, port: u16) -> Result<()> { } } -/// Phase D.1: open the boot URL in the user's browser unless -/// `--no-browser` was passed. `browser` is the `--browser ` -/// value: `Some` opens that specific application (via `open::with`, -/// i.e. `open -a` on macOS), `None` the system default. Failure is -/// logged + non-fatal — the URL was already printed for copy-paste -/// before this fires. -fn open_browser_or_log(url: &str, browser: Option<&str>, suppress: bool) { - if suppress { - return; - } +/// Phase D.1: open the boot URL in the user's browser. `browser` is +/// the `--browser ` value: `Some` opens that specific application +/// (via `open::with`, i.e. `open -a` on macOS), `None` the system +/// default. Failure is logged + non-fatal — the URL was already +/// printed for copy-paste before this fires. Callers gate on +/// `--no-browser` before calling. +fn open_browser_or_log(url: &str, browser: Option<&str>) { let result = match browser { Some(app) => open::with(url, app), None => open::that(url), @@ -656,6 +612,39 @@ fn open_browser_or_log(url: &str, browser: Option<&str>, suppress: bool) { } } +/// bd-a6dvrdg1: spawn the "open the browser once the server accepts +/// connections" task both UI modes share. The server doesn't start +/// until `quarto_preview::run` below (which then blocks until +/// shutdown), so the open has to run on a spawned task that waits for +/// readiness while the main task goes on to start the server. Opening +/// eagerly — as we used to — raced the server's startup: on larger +/// projects the browser connected before `axum::serve` was live and +/// showed "Unable to connect" until a manual reload. The probe +/// (`wait_until_accepting`) closes that race by gating on the real +/// accept condition. The port is the pre-probed one; nothing is +/// listening on it until the server binds, so the probe naturally +/// retries across the gap. A >10 s startup is still considered a bug, +/// but opening anyway (rather than never) preserves the old behavior +/// as a floor, and the warning gives the slow start visibility instead +/// of leaving it silent. +fn spawn_browser_open_when_ready(host: String, port: u16, url: String, browser: Option) { + tokio::spawn(async move { + const READY_TIMEOUT: Duration = Duration::from_secs(10); + if wait_until_accepting(&host, port, READY_TIMEOUT).await { + info!(host = %host, port, "preview server accepting connections; opening browser"); + } else { + tracing::warn!( + host = %host, + port, + timeout_secs = READY_TIMEOUT.as_secs(), + "preview server has not accepted a connection within the timeout; \ + opening the browser anyway (it may need a manual reload)" + ); + } + open_browser_or_log(&url, browser.as_deref()); + }); +} + /// bd-a6dvrdg1: poll `host:port` with `TcpStream::connect` until the /// preview server is accepting connections, or `total_timeout` elapses. /// @@ -870,21 +859,8 @@ fn percent_encode(s: &str, keep_slash: bool) -> String { } /// Phase 4 (bd-jt1etjbn): build the `--ui editor` boot URL — the -/// hub-client share route. The three params ride the *hash fragment* -/// (the SPA router parses `location.hash`, not the URL query; -/// `hub-client/src/utils/routing.ts`), all three are required by the -/// client's validation, and `server=%2Fws` is the relative sync -/// endpoint hub-client resolves against the page origin — which is -/// this preview server. The doc id travels bare: the client re-adds -/// the `automerge:` prefix, and `buildShareableUrl` on the TS side -/// strips it symmetrically. -/// -/// `ephemeral=true` (bd-zf4ryvuq) marks the serving hub as a throwaway -/// per-session preview server: the client captures the flag before the -/// share handler clears the URL, silently establishes a project-set -/// root against `/ws`, and skips the setup/migration gate so the user -/// lands straight in the preview. Only preview boot URLs carry it — -/// `buildShareableUrl` never emits it. +/// hub-client share route against this host's own origin. See +/// [`editor_share_route`] for the route shape and param contract. pub(crate) fn build_editor_boot_url( host: &str, port: u16, @@ -899,12 +875,22 @@ pub(crate) fn build_editor_boot_url( } /// The hub-client share route both editor boot-URL builders emit (host -/// above, guest below). Single source for the route shape: the client's -/// validation requires `server` / `file` / `name`, and `ephemeral=true` -/// (bd-zf4ryvuq) marks the serving hub as a throwaway per-session -/// preview server so the client skips project-set onboarding. The doc -/// id travels bare: the client re-adds the `automerge:` prefix, and -/// `buildShareableUrl` on the TS side strips it symmetrically. +/// above, guest below). Single source for the route shape: the params +/// ride the *hash fragment* (the SPA router parses `location.hash`, not +/// the URL query; `hub-client/src/utils/routing.ts`), the client's +/// validation requires `server` / `file` / `name`, and `server=%2Fws` +/// is the relative sync endpoint hub-client resolves against the page +/// origin — the preview server for the host, the local tunnel proxy +/// for a `--join` guest. The doc id travels bare: the client re-adds +/// the `automerge:` prefix, and `buildShareableUrl` on the TS side +/// strips it symmetrically. +/// +/// `ephemeral=true` (bd-zf4ryvuq) marks the serving hub as a throwaway +/// per-session preview server: the client captures the flag before the +/// share handler clears the URL, silently establishes a project-set +/// root against `/ws`, and skips the setup/migration gate so the user +/// lands straight in the preview. Only preview boot URLs carry it — +/// `buildShareableUrl` never emits it. fn editor_share_route(index_doc_id: &str, file: &str, project_name: &str) -> String { let doc_id = index_doc_id .strip_prefix("automerge:") @@ -934,10 +920,11 @@ fn build_guest_editor_url( } /// Choose the share route's `file` param: the CLI-resolved initial -/// page when there is one, else the lexicographically first `.qmd` -/// known to the index (its files map is unordered; taking the minimum -/// keeps the boot deterministic). `None` when the project has no -/// `.qmd` at all. +/// page when there is one, else a root `index.qmd` when the project +/// has one (the front door of a website project), else the +/// lexicographically first `.qmd` known to the index (its files map is +/// unordered; taking the minimum keeps the boot deterministic). `None` +/// when the project has no `.qmd` at all. pub(crate) fn pick_editor_file( initial_page: Option<&str>, index_paths: &[String], @@ -945,6 +932,9 @@ pub(crate) fn pick_editor_file( if let Some(page) = initial_page { return Some(page.to_string()); } + if index_paths.iter().any(|p| p == "index.qmd") { + return Some("index.qmd".to_string()); + } index_paths .iter() .filter(|p| p.ends_with(".qmd")) @@ -984,21 +974,6 @@ mod tests { ); } - #[test] - fn open_browser_or_log_is_noop_when_suppressed() { - // The `suppress` branch must return without touching the - // OS — we never want a test run to fork a browser. Asserting - // "doesn't panic, returns" is the contract. - open_browser_or_log("https://invalid.example.invalid/", None, true); - // Suppression wins over an explicit --browser too: still no - // OS touch even though the browser name is bogus. - open_browser_or_log( - "https://invalid.example.invalid/", - Some("not-a-real-browser-q2-test"), - true, - ); - } - // ────────────────────────────────────────────────────────────── // Phase D.2 (bd-kw93.13): resolve_project_and_initial_page + // build_boot_url + percent_encode_path @@ -1314,6 +1289,18 @@ mod tests { ); } + #[test] + fn pick_editor_file_prefers_root_index_qmd_over_sorted_first() { + // A website project's front door wins over the deterministic + // sorted-first fallback even when it isn't alphabetically first. + let files = vec!["about.qmd".to_string(), "index.qmd".to_string()]; + assert_eq!(pick_editor_file(None, &files).as_deref(), Some("index.qmd")); + // Only a *root* index.qmd gets the preference — a nested one is + // just another page. + let files = vec!["about.qmd".to_string(), "posts/index.qmd".to_string()]; + assert_eq!(pick_editor_file(None, &files).as_deref(), Some("about.qmd")); + } + #[test] fn pick_editor_file_falls_back_to_first_qmd_sorted() { // The index files map is unordered (HashMap); the fallback must From 4545b4c161fa6dba89c7ed79ea047c790d1a7905 Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:00:39 +0100 Subject: [PATCH 17/22] fix(preview): --join help text notes --browser also composes The conflict matrix already lets guests pass --browser (covered by preview_browser_composes_with_join); the --join doc comment only named --no-browser. --- crates/quarto/src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/quarto/src/main.rs b/crates/quarto/src/main.rs index d1fd9b219..c399844fc 100644 --- a/crates/quarto/src/main.rs +++ b/crates/quarto/src/main.rs @@ -281,7 +281,7 @@ enum Commands { /// the host-mode flags (a path, --share, --no-project, /// --allow-edit, --ui, --data-dir, --preview-dir) don't combine /// with it. --port/--host pick where the local proxy listens; - /// --no-browser still applies. + /// --no-browser and --browser still apply. #[arg( long, value_name = "TICKET", From 1ac411dd1a3d3490058a051492d825b5dce660e5 Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:00:39 +0100 Subject: [PATCH 18/22] test(preview): deflake tunnel tests under CI load The two share.rs tunnel tests were the only current-thread #[tokio::test] fns running an iroh endpoint; every other tunnel test (tunnel.rs, join_tunnel.rs) is multi_thread. On the ubuntu CI runner (slowest cores, fullest suite) the single runtime thread starved the endpoint actors until the 20s step caps were exceeded: share_glue_tunnels... failed at 100.9s, share_task_waits... at 63.5s. - multi_thread flavor for both share.rs tunnel tests (matches the suite convention; locally 19-23s -> 12-14s under in-crate load) - STEP_TIMEOUT 20s -> 60s in share.rs and p2p support.rs: iroh endpoint work inflates 5-10x under full-suite load (each bind pays netmon setup + handshake crypto on contended cores); the caps only bind when something is genuinely broken - join_tunnel convergence poll 15s -> 30s (same load-inflation class) --- .../quarto-p2p/tests/integration/support.rs | 8 ++++++-- .../tests/integration/join_tunnel.rs | 2 +- .../quarto-preview/tests/integration/share.rs | 19 +++++++++++++++---- 3 files changed, 22 insertions(+), 7 deletions(-) diff --git a/crates/quarto-p2p/tests/integration/support.rs b/crates/quarto-p2p/tests/integration/support.rs index 65a0b6c77..a9a90fa37 100644 --- a/crates/quarto-p2p/tests/integration/support.rs +++ b/crates/quarto-p2p/tests/integration/support.rs @@ -8,8 +8,12 @@ use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpStream; /// Generous cap for individual awaits so a broken tunnel fails the test -/// instead of hanging it. -pub const STEP_TIMEOUT: Duration = Duration::from_secs(20); +/// instead of hanging it. Sized for CI, not for the happy path: iroh +/// endpoint operations inflate 5-10x under full-suite load (every +/// endpoint bind pays for netmon setup + handshake crypto on contended +/// cores), and ubuntu runners are the slowest we have. Never binds on a +/// healthy run — the uncontended cost of a step is milliseconds. +pub const STEP_TIMEOUT: Duration = Duration::from_secs(60); pub fn hermetic_host_cfg() -> TunnelHostConfig { TunnelHostConfig { diff --git a/crates/quarto-preview/tests/integration/join_tunnel.rs b/crates/quarto-preview/tests/integration/join_tunnel.rs index ff0fe6710..c2f6057c9 100644 --- a/crates/quarto-preview/tests/integration/join_tunnel.rs +++ b/crates/quarto-preview/tests/integration/join_tunnel.rs @@ -149,7 +149,7 @@ async fn guest_syncs_project_through_tunnel() { .expect("index document found through tunnel"); // The files map converges to the fixture's two pages. - let deadline = Instant::now() + Duration::from_secs(15); + let deadline = Instant::now() + Duration::from_secs(30); let files = loop { let files = index.get_all_files(); if files.contains_key("index.qmd") && files.contains_key("about.qmd") { diff --git a/crates/quarto-preview/tests/integration/share.rs b/crates/quarto-preview/tests/integration/share.rs index c283b9a5d..f3d749ac8 100644 --- a/crates/quarto-preview/tests/integration/share.rs +++ b/crates/quarto-preview/tests/integration/share.rs @@ -10,8 +10,12 @@ use quarto_p2p::{EndpointPreset, TunnelClient, TunnelClientConfig, TunnelHostCon use quarto_preview::share::{format_share_banner, spawn_share_task, start_share_session}; /// Generous cap for individual awaits so a broken tunnel fails the test -/// instead of hanging it. -const STEP_TIMEOUT: Duration = Duration::from_secs(20); +/// instead of hanging it. Sized for CI, not for the happy path: iroh +/// endpoint operations inflate 5-10x under full-suite load (every +/// endpoint bind pays for netmon setup + handshake crypto on contended +/// cores), and ubuntu runners are the slowest we have. Never binds on a +/// healthy run — the uncontended cost of a step is milliseconds. +const STEP_TIMEOUT: Duration = Duration::from_secs(60); fn hermetic_host_cfg() -> TunnelHostConfig { TunnelHostConfig { @@ -23,7 +27,13 @@ fn hermetic_host_cfg() -> TunnelHostConfig { /// The core Phase 2 unit: the share glue spawns a tunnel whose target is /// `127.0.0.1:{port}` (the pre-resolved preview port), and the join /// banner goes through the injected callback — no stdout scraping. -#[tokio::test] +/// +/// multi_thread like every other test that runs an iroh endpoint +/// (tunnel.rs, join_tunnel.rs): on a current-thread runtime the endpoint +/// actors, the axum stand-in, and reqwest all share one thread, and +/// under CI load the 20s step caps below get exceeded (ubuntu CI +/// failure, 2026-08-09). +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn share_glue_tunnels_to_preview_port_and_announces_join_string() { // Tiny axum server standing in for the preview hub on the loopback // port the CLI would have pre-resolved. @@ -118,7 +128,8 @@ async fn share_glue_tunnels_to_preview_port_and_announces_join_string() { /// print after the boot URL. Nothing may start before the gate fires — /// with the hermetic preset an ungated spawn would finish in /// milliseconds, so an early banner proves the gate is broken. -#[tokio::test] +/// multi_thread: see `share_glue_tunnels_to_preview_port_and_announces_join_string`. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn share_task_waits_for_server_ready_gate() { // Stand-in for the preview hub, as in the glue test above. let listener = tokio::net::TcpListener::bind("127.0.0.1:0") From 6ffe52119273dff9db86dfad13fadded7060b79a Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:00:39 +0100 Subject: [PATCH 19/22] fix(preview): retry ephemeral-banner config fetch once; extract banner component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A --join guest's boot fetch of /api/preview/config can race the tunnel's connect handshake; since the config is fetched once per boot, a dropped request hid the ephemeral-session banner for the whole session. Retry once on transport failure only — definitive answers (non-ok, HTML fallback, malformed body) stay single-shot so standalone hubs see no extra request. The banner moves from inline JSX in Editor (untestable in jsdom — Monaco imports) to a tiny EphemeralSessionBanner component with a render test pinning its copy, status role, and hover text. --- hub-client/src/components/Editor.tsx | 11 +---- .../EphemeralSessionBanner.test.tsx | 36 +++++++++++++++++ .../src/components/EphemeralSessionBanner.tsx | 21 ++++++++++ hub-client/src/services/previewConfig.test.ts | 36 +++++++++++++++-- hub-client/src/services/previewConfig.ts | 40 ++++++++++++++----- 5 files changed, 123 insertions(+), 21 deletions(-) create mode 100644 hub-client/src/components/EphemeralSessionBanner.test.tsx create mode 100644 hub-client/src/components/EphemeralSessionBanner.tsx diff --git a/hub-client/src/components/Editor.tsx b/hub-client/src/components/Editor.tsx index 24e5e85a8..56189507d 100644 --- a/hub-client/src/components/Editor.tsx +++ b/hub-client/src/components/Editor.tsx @@ -34,6 +34,7 @@ import { useReplayMode } from '../hooks/useReplayMode'; import { useAutomergeSync } from '../hooks/useAutomergeSync'; import { diffToMonacoEdits } from '../utils/diffToMonacoEdits'; import { diagnosticsToMarkers } from '../utils/diagnosticToMonaco'; +import EphemeralSessionBanner from './EphemeralSessionBanner'; import FileSidebar from './FileSidebar'; import NewFileDialog from './NewFileDialog'; import NewAssetDialog from './NewAssetDialog'; @@ -999,15 +1000,7 @@ export default function Editor({ project, files, fileContents, onDisconnect, onC
)} - {!isFullscreenPreview && sessionEphemeral && ( -
- Ephemeral session — edits won't be saved to disk -
- )} + {!isFullscreenPreview && sessionEphemeral && } {!isFullscreenPreview && unlocatedErrors.length > 0 && (
diff --git a/hub-client/src/components/EphemeralSessionBanner.test.tsx b/hub-client/src/components/EphemeralSessionBanner.test.tsx new file mode 100644 index 000000000..1ed140066 --- /dev/null +++ b/hub-client/src/components/EphemeralSessionBanner.test.tsx @@ -0,0 +1,36 @@ +/** + * Tests for the ephemeral-session banner shown in the editor when the + * serving `q2 preview` session runs without --allow-edit. + * + * The data path behind it (fetch + validation of /api/preview/config) + * is covered by previewConfig.test.ts and usePreviewSession.test.tsx; + * these tests pin the banner's rendered contract: the copy a user + * sees, the status role, and the hover text naming the --allow-edit + * fix. + * + * @vitest-environment jsdom + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import EphemeralSessionBanner from './EphemeralSessionBanner'; + +afterEach(cleanup); + +describe('EphemeralSessionBanner', () => { + it('renders the ephemeral-session copy as a status region', () => { + render(); + + const banner = screen.getByRole('status'); + expect(banner.className).toBe('ephemeral-session-banner'); + expect(banner.textContent).toContain("edits won't be saved to disk"); + }); + + it('hover text explains the cause and names the --allow-edit fix', () => { + render(); + + const { title } = screen.getByRole('status'); + expect(title).toContain('never written'); + expect(title).toContain('--allow-edit'); + }); +}); diff --git a/hub-client/src/components/EphemeralSessionBanner.tsx b/hub-client/src/components/EphemeralSessionBanner.tsx new file mode 100644 index 000000000..fee049d59 --- /dev/null +++ b/hub-client/src/components/EphemeralSessionBanner.tsx @@ -0,0 +1,21 @@ +/** + * EphemeralSessionBanner — the persistent banner for `q2 preview + * --ui editor` sessions started without --allow-edit: edits sync live + * to everyone connected but are never written to the host's files. + * + * Editor renders this when App wires `sessionEphemeral` (the serving + * server's /api/preview/config reports `allowEdit === false`; a --join + * guest reads the host's value through the tunnel). The copy is fixed + * — nothing here is interpolated from server data. + */ +export default function EphemeralSessionBanner() { + return ( +
+ Ephemeral session — edits won't be saved to disk +
+ ); +} diff --git a/hub-client/src/services/previewConfig.test.ts b/hub-client/src/services/previewConfig.test.ts index 0353ac7e4..e1ef2be0c 100644 --- a/hub-client/src/services/previewConfig.test.ts +++ b/hub-client/src/services/previewConfig.test.ts @@ -62,6 +62,8 @@ describe('fetchPreviewSessionConfig', () => { vi.mocked(fetch).mockResolvedValue(jsonResponse({}, { ok: false, status: 404 })); await expect(fetchPreviewSessionConfig()).resolves.toBeNull(); + // A definitive answer is not retried. + expect(fetch).toHaveBeenCalledTimes(1); }); it('returns null when the body is SPA-fallback HTML (json() throws)', async () => { @@ -72,6 +74,8 @@ describe('fetchPreviewSessionConfig', () => { } as unknown as Response); await expect(fetchPreviewSessionConfig()).resolves.toBeNull(); + // SPA-fallback HTML is a definitive answer: not retried. + expect(fetch).toHaveBeenCalledTimes(1); }); it.each([{}, { allowEdit: 'false' }, { allowEdit: 0 }, null, 'allowEdit'])( @@ -83,10 +87,36 @@ describe('fetchPreviewSessionConfig', () => { }, ); - it('returns null on network error', async () => { - vi.mocked(fetch).mockRejectedValue(new TypeError('fetch failed')); + it('retries a transport failure once and returns the retried config', async () => { + vi.useFakeTimers(); + try { + vi.mocked(fetch) + .mockRejectedValueOnce(new TypeError('fetch failed')) + .mockResolvedValueOnce(jsonResponse({ allowEdit: false })); + + const pending = fetchPreviewSessionConfig(); + // Advance well past the retry delay (RETRY_DELAY_MS, 750ms). + await vi.advanceTimersByTimeAsync(5_000); + await expect(pending).resolves.toEqual({ allowEdit: false }); + expect(fetch).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } + }); - await expect(fetchPreviewSessionConfig()).resolves.toBeNull(); + it('returns null when the fetch and its single retry both fail', async () => { + vi.useFakeTimers(); + try { + vi.mocked(fetch).mockRejectedValue(new TypeError('fetch failed')); + + const pending = fetchPreviewSessionConfig(); + await vi.advanceTimersByTimeAsync(5_000); + await expect(pending).resolves.toBeNull(); + // One retry, not a loop: exactly two attempts. + expect(fetch).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } }); it('prefixes the path with VITE_HUB_BASE_PATH when set', async () => { diff --git a/hub-client/src/services/previewConfig.ts b/hub-client/src/services/previewConfig.ts index 01cf74091..abf52b471 100644 --- a/hub-client/src/services/previewConfig.ts +++ b/hub-client/src/services/previewConfig.ts @@ -23,22 +23,44 @@ export interface PreviewSessionConfig { allowEdit: boolean; } +/** Delay before the single retry of a transport-failed config fetch. */ +const RETRY_DELAY_MS = 750; + /** * Fetch the preview session config, or null when the serving server is * not a `q2 preview` session (standalone hub, dev server) or the fetch * fails. Only a response carrying an explicit boolean `allowEdit` * counts — SPA-fallback HTML and older servers both yield null. + * + * A transport-level failure (fetch rejects) is retried once: for a + * `--join` guest the boot fetch can race the tunnel's connection + * handshake, and the config is fetched only once per boot — a dropped + * request would hide the ephemeral-session banner for the whole + * session. Definitive answers (non-ok status, non-JSON or malformed + * body) are not retried: on a standalone hub they are the expected + * "not a preview session" signal. */ export async function fetchPreviewSessionConfig(): Promise { - try { - const res = await fetch(hubPath('/api/preview/config'), { credentials: 'same-origin' }); + for (let attempt = 0; ; attempt++) { + let res: Response; + try { + res = await fetch(hubPath('/api/preview/config'), { credentials: 'same-origin' }); + } catch { + if (attempt === 0) { + await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS)); + continue; + } + return null; + } if (!res.ok) return null; - const data: unknown = await res.json(); - if (typeof data !== 'object' || data === null) return null; - const { allowEdit } = data as { allowEdit?: unknown }; - if (typeof allowEdit !== 'boolean') return null; - return { allowEdit }; - } catch { - return null; + try { + const data: unknown = await res.json(); + if (typeof data !== 'object' || data === null) return null; + const { allowEdit } = data as { allowEdit?: unknown }; + if (typeof allowEdit !== 'boolean') return null; + return { allowEdit }; + } catch { + return null; + } } } From c40940e9205dc4daebb7cc2cb18ad54b9ddaff29 Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:06:08 +0100 Subject: [PATCH 20/22] docs(hub-client): changelog entries for preview live-share UI Catches up the branch's hub-client changes (the --ui editor embed, ephemeral project-set boot, and the ephemeral-session banner) plus the config-fetch retry. --- hub-client/changelog.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/hub-client/changelog.md b/hub-client/changelog.md index 675f43631..7cc56ac54 100644 --- a/hub-client/changelog.md +++ b/hub-client/changelog.md @@ -23,6 +23,13 @@ WASM rebuild is needed for a changelog-only edit. --> +### 2026-08-09 + +- [`6ffe5211`](https://github.com/quarto-dev/q2/commits/6ffe5211): The ephemeral-session banner's config fetch now retries once on a transient network failure, so a dropped first request (for example a `--join` guest's tunnel mid-handshake) no longer hides the banner for the whole session. +- [`4111452f`](https://github.com/quarto-dev/q2/commits/4111452f): The editor shows a persistent "Ephemeral session — edits won't be saved to disk" banner when the serving `q2 preview` was started without `--allow-edit`; guests of a shared session see it too. +- [`bcfbb47f`](https://github.com/quarto-dev/q2/commits/bcfbb47f): `q2 preview --ui editor` boots skip the project-set setup and migration screens and land straight in the previewed document. +- [`9dfce50e`](https://github.com/quarto-dev/q2/commits/9dfce50e): `q2 preview --ui editor` serves the full collaborative editor (Monaco, file sidebar, live preview pane) from the preview server, to the host and any `--join` guests alike. + ### 2026-08-07 - [`279d7e5e`](https://github.com/quarto-dev/q2/commits/279d7e5e): `.md` files are now first-class source files: they sync into hub projects, get live preview, outline, folding, diagnostics, and qmd highlighting in the editor, and cross-document links into `.md` pages navigate like `.qmd` ones. From a04c694fea81b263b25e8e7b90253cf02d87b972 Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:35:23 +0100 Subject: [PATCH 21/22] test(quarto-p2p): poll the fixed-port host respawn in the redial test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TunnelHostHandle::shutdown is graceful but not synchronous with the UDP socket's release (iroh gives endpoint tasks a grace window after close), so the immediate rebind raced the teardown and failed with EADDRINUSE on the loaded ubuntu CI runner. Poll the respawn until the port is free (10s deadline). A real host restart can't hit this — process exit releases the socket. --- crates/quarto-p2p/tests/integration/tunnel.rs | 33 ++++++++++++++----- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/crates/quarto-p2p/tests/integration/tunnel.rs b/crates/quarto-p2p/tests/integration/tunnel.rs index 109eac26d..2268a837c 100644 --- a/crates/quarto-p2p/tests/integration/tunnel.rs +++ b/crates/quarto-p2p/tests/integration/tunnel.rs @@ -272,15 +272,32 @@ async fn client_redials_after_connection_loss() { .expect("status channel closed"); // Restart the host: same identity, token, target, and UDP port. - let cfg = TunnelHostConfig { - preset: EndpointPreset::HermeticLoopback, - secret_key: Some(secret_key), - token: Some(token), - bind_addr: Some(udp_addr), + // `shutdown()` is graceful but not synchronous with the UDP socket's + // release — iroh gives endpoint tasks a grace window to finish after + // it returns, and on a loaded CI runner an immediate rebind can race + // the teardown (EADDRINUSE, ubuntu CI 2026-08-09). Poll until the + // port is free. (A real host restart can't hit this: process exit + // releases the socket.) + let respawn_deadline = Instant::now() + Duration::from_secs(10); + let (restart_ticket, second_host) = loop { + let cfg = TunnelHostConfig { + preset: EndpointPreset::HermeticLoopback, + secret_key: Some(secret_key.clone()), + token: Some(token), + bind_addr: Some(udp_addr), + }; + match TunnelHost::spawn(cfg, target).await { + Ok(pair) => break pair, + Err(e @ quarto_p2p::TunnelError::Bind(_)) => { + assert!( + Instant::now() < respawn_deadline, + "respawn host on the same udp addr: still failing after 10s: {e}" + ); + tokio::time::sleep(Duration::from_millis(100)).await; + } + Err(e) => panic!("respawn host on the same udp addr: {e}"), + } }; - let (restart_ticket, second_host) = TunnelHost::spawn(cfg, target) - .await - .expect("respawn host on the same udp addr"); assert_eq!(restart_ticket.addr.id, ticket.addr.id); // The next local TCP connections succeed once the client re-dialed. From 445fa1429fac4946d81110680ef5b5af1da8caea Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:46:51 +0100 Subject: [PATCH 22/22] feat(preview): print a Ctrl-C shutdown line on the host side MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `q2 preview --join` prints "Received Ctrl-C, leaving the shared session…", but the host side printed nothing — the hub's own shutdown notice goes to tracing::info, hidden at the CLI's default filter. Arm a small ctrl_c printer alongside the server (tokio allows multiple listeners; the hub's handler still drives the graceful shutdown and final filesystem sync), with a share-specific variant ("ending the shared session…") when --share is active. Covered by a unix subprocess test that boots the real binary, waits for the port to accept, sends SIGINT, and asserts the line and the clean exit. --- crates/quarto/src/commands/preview.rs | 37 ++++++++- .../quarto/tests/integration/preview_cli.rs | 79 +++++++++++++++++++ 2 files changed, 115 insertions(+), 1 deletion(-) diff --git a/crates/quarto/src/commands/preview.rs b/crates/quarto/src/commands/preview.rs index 335b6cddd..92d9c0875 100644 --- a/crates/quarto/src/commands/preview.rs +++ b/crates/quarto/src/commands/preview.rs @@ -247,7 +247,20 @@ async fn run(args: PreviewArgs) -> Result<()> { ui: args.ui, }; - match args.ui { + // Host-side Ctrl-C acknowledgment, symmetric with the guest's line + // in run_join: the hub's own "initiating graceful shutdown" goes to + // tracing::info, which the CLI's default filter hides — so without + // this the process just vanished. tokio allows any number of ctrl_c + // listeners; the hub's own handler still drives the graceful + // shutdown (and its final filesystem sync). + let sharing = args.share; + let ctrl_c_printer = tokio::spawn(async move { + let _ = tokio::signal::ctrl_c().await; + println!(); + println!(" {}", shutdown_message(sharing)); + }); + + let result = match args.ui { quarto_preview::PreviewUi::Viewer => quarto_preview::run(config).await, quarto_preview::PreviewUi::Editor => { // Phase 4 (bd-jt1etjbn): the share-route boot URL needs the @@ -312,6 +325,20 @@ async fn run(args: PreviewArgs) -> Result<()> { }) .await } + }; + // The server exited on its own (error path): disarm the printer so + // a late Ctrl-C can't announce a shutdown that already happened. + ctrl_c_printer.abort(); + result +} + +/// The host's Ctrl-C line: a plain preview vs. an active `--share` +/// session (whose guests lose their tunnel when the host exits). +fn shutdown_message(share: bool) -> &'static str { + if share { + "Received Ctrl-C, ending the shared session…" + } else { + "Received Ctrl-C, shutting down the preview…" } } @@ -1476,4 +1503,12 @@ mod tests { let ready = wait_until_healthy(addr, std::time::Duration::from_millis(200)).await; assert!(!ready, "no listener → not healthy"); } + + #[test] + fn shutdown_message_marks_shared_sessions() { + // The share variant tells the host (and anyone watching) that + // guests are about to lose their tunnel. + assert!(shutdown_message(true).contains("ending the shared session")); + assert!(shutdown_message(false).contains("shutting down the preview")); + } } diff --git a/crates/quarto/tests/integration/preview_cli.rs b/crates/quarto/tests/integration/preview_cli.rs index fed2b5dca..b6c324a41 100644 --- a/crates/quarto/tests/integration/preview_cli.rs +++ b/crates/quarto/tests/integration/preview_cli.rs @@ -77,3 +77,82 @@ fn preview_help_accepts_positional_path() { "help output missing `Usage: ... preview ...` line:\n{help}" ); } + +/// Ctrl-C on the host prints a shutdown line (live-share review +/// follow-up): the hub's own "initiating graceful shutdown" is +/// tracing::info and invisible at the CLI's default filter, so the +/// process used to just vanish — while `--join` guests have always had +/// the friendly "leaving the shared session" line. Boots the real +/// binary in --no-project mode, waits for the port to accept, sends +/// SIGINT, and checks the printed line and the clean exit. Unix-only: +/// relies on `kill -INT`. +#[cfg(unix)] +#[test] +fn preview_prints_shutdown_message_on_ctrl_c() { + use std::io::{BufRead, BufReader, Read}; + use std::process::Stdio; + use std::time::{Duration, Instant}; + + let temp = tempfile::TempDir::new().expect("tempdir"); + let mut child = Command::new(Q2_BIN) + .args(["preview", "--no-project", "--no-browser"]) + .current_dir(temp.path()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn q2 preview"); + + let mut stdout = BufReader::new(child.stdout.take().expect("stdout piped")); + + // Read to the boot URL line, then poll the port until the server + // accepts — by then every Ctrl-C handler (the hub's own and the + // CLI's printer) is armed, so the signal can't slip through a + // startup gap. + let mut line = String::new(); + let boot_line = loop { + line.clear(); + let n = stdout.read_line(&mut line).expect("read boot line"); + assert!(n > 0, "preview exited before printing its boot URL"); + if line.contains("→ http") { + break line.trim().to_string(); + } + }; + let port: u16 = boot_line + .rsplit(':') + .next() + .expect("boot URL has a port") + .trim_end_matches('/') + .parse() + .expect("boot URL port parses"); + let deadline = Instant::now() + Duration::from_secs(10); + while std::net::TcpStream::connect(("127.0.0.1", port)).is_err() { + assert!( + Instant::now() < deadline, + "preview port {port} never accepted a connection" + ); + std::thread::sleep(Duration::from_millis(50)); + } + + // The terminal Ctrl-C, delivered as SIGINT. + let status = Command::new("kill") + .args(["-INT", &child.id().to_string()]) + .status() + .expect("run kill -INT"); + assert!(status.success(), "kill -INT failed"); + + // Draining stdout to EOF waits out the graceful shutdown (the + // exiting process closes the pipe). + let mut rest = String::new(); + stdout + .read_to_string(&mut rest) + .expect("drain stdout after SIGINT"); + let status = child.wait().expect("wait on child"); + assert!( + status.success(), + "preview must exit cleanly on Ctrl-C, got {status:?}" + ); + assert!( + rest.contains("Received Ctrl-C, shutting down the preview"), + "missing the Ctrl-C shutdown line; got:\n{rest}" + ); +}