Live share for q2 preview - #464
Conversation
✅ Snyk checks have passed. No issues have been found so far.
💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse. |
7cca372 to
9fb1ade
Compare
d048c63 to
aa57f73
Compare
|
whoa! iroh stuff!!? This sounds super awesome |
8432b20 to
71fb777
Compare
|
I read through the I was wondering if you've factored this PR such that we have an "iroh automerge network adapter"? I found this existing automerge page in the iroh docs. It doesn't seem to be a network adapter? It seems very simple. I don't really know whats going on but its cool. |
71fb777 to
3682d02
Compare
|
@vezwork it's not using any of the automerge-specific adaptors as our iroh tunnel is implemented lower level to tunnel all our http traffic - this includes several REST endpoints as well as the websocket connection. That's also why there's minimal change to the existing code. |
|
... huh. One half of this session can even live on a pure webpage! https://docs.iroh.computer/languages/wasm-browser |
huh, I think you're right. So joining a session could theoretically not even need q2 installed! |
…bd-9gam4jqe)
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)
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.
…-v8mwzpmi) Implements the tunnel under the tests landed in c146ca6 (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).
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 <TICKET>` 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
Guest side of live share: `q2 preview --join <ticket>` 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
…the real n0 relay (bd-6y0p1bne) 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.
Serve the full hub-client editor from the preview server via
--ui <viewer|editor> (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
…f4ryvuq) 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.
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.
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.
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 <name> 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.
…s off 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.
…le pick, dedupe - --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.
The conflict matrix already lets guests pass --browser (covered by preview_browser_composes_with_join); the --join doc comment only named --no-browser.
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)
…r component 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.
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.
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.
`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.
… (bd-wj9smyxg) preview_prints_shutdown_message_on_ctrl_c flaked on ubuntu CI (run 31538840556): stdout drained empty with a clean exit. The CLI's detached ctrl_c_printer task raced the hub's graceful shutdown — when teardown finished first, the process exited before the printer task was ever scheduled. The message now prints from the hub's own signal-listener task, via the new HubConfig::shutdown_message, before the shutdown signal is forwarded — on the shutdown critical path, so it can never be lost to scheduling. quarto-preview sets it from config.share (wording moved from the CLI); the standalone hub binaries leave it None. SIGTERM stays log-only since the message reads "Received Ctrl-C". Also syncs Cargo.lock quarto-p2p version with workspace 0.16.0.
… (bd-lbvtfejg) Plan: claude-notes/plans/2026-08-13-live-share-local-spa-assets.md Baseline (harness: scripts/join-boot-baseline/, numbers in the plan): viewer boot = 20 requests + 1 WS upgrade, 54,672,999 B; first render 0.67 s direct loopback, 3.2 s relay-pinned (spike pair, zero DIRECT selections), 48.0 s at 10 Mbps/100 ms through the real --share/--join stack. 4.07 MB of the boot is duplicate fetches (no cache headers today); tree-sitter wasm and KaTeX fonts are not fetched at boot for a plain document. Phases 1-3 test skeletons land #[ignore]d so the workspace stays green; each phase un-ignores and starts red (22 fail for the right reasons when forced via --run-ignored only; 2 deliberate guards pass today). brotli added as a quarto-preview dev-dep for the .br round-trip test. No snapshot changes.
…cq3c) Plan: claude-notes/plans/2026-08-13-live-share-local-spa-assets.md WASM 41.9 -> 26.9 MB identity (fat LTO + opt-level="s" + codegen-units=1 in the wasm crate's release profile, then wasm-opt -Oz as build:wasm step 3; each knob measured independently — thin LTO alone regressed +11%). wasm-opt is located by build-wasm.js (PATH, then Homebrew binaryen prefix) and checked by cargo dev-setup. .gz precompression (gz-only decision, supersedes the same-day .br-only decision — maximum Accept-Encoding compatibility, flate2 already in the tree) via scripts/precompress-dist.mjs wired into the SPA npm builds themselves: single producer, so verify step 13's bare npm run build can't wipe the siblings. asset_response now owns every asset-path header: the local-prod cache contract (immutable /assets/*, no-cache elsewhere), gzip negotiation (Content-Encoding + unconditional Vary, gzip;q=0 refusal honored), explicit Content-Length, and HEAD semantics — the single helper the Phase 3 join frontend will share. Measured (same fixture + harness as the Phase 0 baseline): wire payload 54.67 -> 10.36 MB (5.3x); first render 48.0 -> 10.0 s at 10 Mbps/100 ms (gate: > 5 s, so Phases 2-3 proceed), 3.2 -> 2.1 s relay-pinned. Binary 255.3 -> 213.7 MB: +17.0 MB of .gz offset by wasm-opt and the editor-embed dedupe realigning (viewer/editor WASM hashes match again). asset_serving.rs un-ignored, 4/4 green; brotli dev-dep removed before landing (flate2 decodes the round-trip). Full cargo xtask verify green (11,896 Rust tests; hub-client test:ci incl. 131 wasm tests against the -Oz module; tree-sitter 601/601 + CRLF parity).
…hase 2, bd-ee2fqm95)
A --join guest necessarily has the q2 binary installed, and that binary
embeds the same SPA bundles the host serves. When the guest's embedded
copy is byte-identical to the host's, assets can be served locally and
only the dynamic traffic needs the tunnel. Compatibility is verified,
never assumed: the host advertises manifest hashes in
GET /api/preview/config and the guest compares against its own embed.
- New crates/spa-manifest: manifest format + generator. Sorted
(path, sha256, size, contentType, contentEncoding?) entries; top-level
hash over \0-terminated canonical field lines (deliberately not JSON,
so any implementation reproduces it); .gz siblings fold into
contentEncoding; the manifest never lists itself. Known-answer hash
vector computed independently via Python hashlib.
- Viewer manifest's single producer is the npm build
(scripts/manifest-dist.mjs as the final npm-run-build step) — the
Phase 1 single-producer move, since verify step 13's bare npm build
would wipe an xtask-written manifest. build.rs writes the editor
manifest over the post-resolution view (viewer fallback first,
editor wins). npm<->Rust agreement pinned by an equivalence test on
the real dist.
- preview_config_handler advertises assets.{viewer,editor}; the block
is omitted under SPA_DIR_OVERRIDE and fields are omitted for
placeholder embeds (guests tunnel; self-healing).
- The join preflight parses assets alongside editorBoot from one config
fetch, decides Local vs Tunnel for the session UI, and logs the
decision (Phase 3 acts on it).
- Release CI: each build leg records `q2 preview
--print-asset-manifest-hashes` (new hidden diagnostic) and the new
asset-manifest-check job fails the release on cross-platform drift.
Plan: claude-notes/plans/2026-08-13-live-share-local-spa-assets.md
Full workspace nextest green (11,913); cargo xtask verify
--skip-hub-build green; e2e inspected (/api/preview/config carries both
hashes matching the on-disk manifest).
…, bd-tl2j8js8) quarto-p2p: split TunnelClient::connect out of bind (design decision 1) — TunnelConnection owns the endpoint, initial dial, supervisor and status watch, and exposes open_stream (token prefix applied internally, budget-parameterized so probes never cancel mid-open: a stream reset before its token lands is treated by the host as an auth failure). bind is reimplemented as connect + the splice accept loop; the tunnel.rs suite stays green unmodified. quarto-preview join_frontend.rs: bounded head-peek (64 KiB / 5 s) with per-connection routing — exact manifest-hit GET/HEAD served from the embedded bundle via the shared asset_response builder (split into asset_response_parts + a thin axum wrapper; header logic never forked), Connection: close on local responses; everything else spliced verbatim onto a tunnel stream. No local index fallback; oversize head gets 431 + close (FIN + bounded drain so the kernel doesn't RST the response away); stalled heads close. run_join: /health + /api/preview/config preflight over raw bi-streams before the local port exists (design decision 6), then the frontend on hash match or the plain splice otherwise; the mode is user-visible. Tests: connect.rs 2/2, join_frontend.rs 8/8 (request-logging TCP shim observes tunnel traffic), CLI preflight tests moved to hermetic tunnels. Workspace: 11,923 nextest green; cargo xtask verify --skip-hub-build green. E2E inspected through the real --share/--join binaries: assets served locally (connection: close marker), /health + /api/preview/config + unknown paths tunneled.
…-2mpka14m, epic bd-puc7xt6e) The real-browser end-to-end verification caught a design gap the per-connection integration tests could not: Chromium reuses idle *tunneled* keep-alive connections for later requests, so ~8.7 MB of the ~10.4 MB boot payload crossed the tunnel unrouted. The frontend now forces Connection: close into every tunneled non-upgrade head (a hop-by-hop header a proxy owns), so each tunneled connection carries one request and every follow-up gets its own routing decision; upgrade heads stay byte-identical. - join_frontend: with_connection_close + 5 unit tests; integration tests tunneled_head_carries_connection_close (red pre-fix) and upgrade_head_passes_verbatim; request-logging shim records raw heads - guest debug log names each tunneled request line - measured through the real --share/--join pair: ~10.77 MB served locally, ~3 KB tunneled, first render 762 ms direct; 1511 ms vs 9945 ms (local vs all-tunnel) at 10 Mbps/100 ms on the throttled tunnel path (spike host + new preview-ticket-from-parts example) - --join --help documents embedded-asset serving + mismatch fallback - plan file: Phase 4 results + design decision 3 amendment; harness README gains the throttled-tunnel leg - braid: epic bd-puc7xt6e closed; follow-ups bd-61ouzwmb (guest boot URL lacks ?page=) and bd-zfjhi7ij (leaky WS splice test) filed; .braid/snapshot.jsonl regenerated (1766 strands) Full workspace nextest 11930 passed; cargo xtask verify --skip-hub-build green (manifest 885dc318… unchanged).
…elease workflows Phase 1 of the live-share payload plan made wasm-opt -Oz a required build:wasm step, but no workflow installed binaryen, so the hub-client E2E run failed at 'wasm-opt (binaryen) not found' (and ts-test-suite's build:all and the release web-payloads job would follow). Install binaryen@132.0.0 via npm -g before each wasm build — pinned to the Homebrew version local dev uses so CI and dev builds produce the same wasm bytes. test-suite.yml needs nothing (no hub build leg); build-wasm.yml is the unrelated wasm-qmd-parser wasm-pack flow.
In a q2 preview --share --ui editor --allow-edit session, a guest who creates a file (New File dialog / asset upload) got a VFS-only document: the automerge doc and index entry synced to the hub, but sync_all_documents skipped the missing disk file before consulting DiskWritePolicy, so the file never landed on disk — contradicting the --allow-edit banner. The missing-file branch now discriminates on SyncState::has_checkpoint: no checkpoint + WriteBack => VFS-created => materialize on disk via the new create_file_from_document (nearest-existing-ancestor canonicalize containment, create_dir_all + write, checkpoint on success so the next sync is a NoChanges no-op, including the watcher echo); checkpoint present => deleted on disk, keep skipping (never resurrect). ReadOnly is unchanged (skip, now logged at debug! — expected VFS-only file in an ephemeral session — while the has-checkpoint deletion case keeps warn!). Known limitation (unchanged from before): a client-side rename reuses the doc_id, so the renamed file has a checkpoint and is treated as deleted-on-disk — the new path is not created. Plan: claude-notes/plans/2026-08-13-vfs-new-file-writeback.md
…iw7sx) Plan complete: real-binary e2e with iroh join (8/8 checks), follow-up beads bd-5vq3mevj (rename persistence), bd-tdttx61g (cap-std), bd-969cwvij (guest boot WASM pageerror).
Replace quarto-preview's include_dir! embeds (identity bytes plus per-file .gz siblings, ~107 MiB) with identity-only tar.zst archives (12.1 MiB), decompressed lazily on first asset request. gzip responses are generated at runtime via flate2 (level 9, cached per file), mirroring the precompress pass's skip set so the wire contract is unchanged. - build.rs archives both embed dirs (post-dedupe, post-manifest) into deterministic tar.zst (sorted entries, forward-slash paths, mtime 0, zstd -19) - EmbeddedBundle decompresses behind a OnceLock: q2 render never pays - Release binary: 181.8 -> 118.9 MiB (-62.9 MiB, -34.6%) Plan: claude-notes/plans/2026-08-13-preview-embed-tar-zst.md No snapshot files changed.
The runtime gzip path (gz_compressible in quarto-preview) and the build-time precompress pass (scripts/precompress-dist.mjs) each carried their own copy of the already-compressed-containers skip set, free to drift — a divergence would change the wire contract between the disk-served and embedded-asset paths. Both now parse a single source of truth: the .mjs reads it at build time; Rust embeds it via include_str! behind a OnceLock (lookup-only, never iterated). gz_compressible_mirrors_the_precompress_skip_set now derives its skip cases from the shared file (edits to it are tested automatically) and asserts precompress-dist.mjs still consumes it, guarding against a re-inlined private list. Follow-up to bd-rem4bpee. No snapshot files changed.
6912985 to
5901793
Compare
…-sw4xy1vw) Every q2 preview session serves the editor embed from a fresh origin (random loopback port), so anything hub-client persisted to IndexedDB — the automerge document cache and the quarto-hub records (project entries, identity, project-set pointer) — was never read again and accumulated unboundedly across sessions (documented Phase 4 wart in the live-share plan; the follow-up strand had never been filed). The preview-embed build now sets VITE_EPHEMERAL_STORAGE=1 (same pattern as VITE_DISABLE_PWA=1). When on: - the per-sync-server automerge Repo and the sync-client connect / createNewProject paths use MemoryStorageAdapter (the viewer SPA's adapter) instead of IndexedDBStorageAdapter; - getDb() returns an in-memory facade of the IDB subset the projectStorage / userSettings / projectSetStorage modules use, so the quarto-hub database is never created (consumers untouched); - App derives the ephemeral-hub flag from the build flag as well as the boot URL, so onboarding gates stay off after a reload; - a project/file route miss (every reload: the in-memory entry is gone) fetches /api/preview/config and rebuilds the session from the server's editorBoot params, reusing the share-flow connect path. Side effects: the project-set migration-screen wart disappears (in-memory listProjects is always empty on boot), and the --ui help text's stale-storage caveat is removed. Verified end-to-end (plan file has the full record): real Chromium session against q2 preview --ui editor — indexedDB.databases() shows no automerge/quarto-hub DBs after boot, and a page reload recovers the session with no 'Project not found'. npm run test / test:ci / build:all / build:preview-embed, cargo nextest -p quarto --bin q2, and cargo xtask verify --skip-hub-build --skip-hub-tests all green. quarto-cache (wasm-js-bridge artifact cache, viewer too) still persists per origin — filed bd-91mdd056. Pre-existing boot-time WASM-init pageerror filed as bd-scudmryg.
…056) The WASM bridge cache (ts-packages/wasm-js-bridge/src/cache.js — SASS/ theme artifacts driven from Rust via wasm-bindgen) always opened a 'quarto-cache' IndexedDB database. Every q2 preview session is a fresh origin (random loopback port), so these databases were never read again and accumulated across sessions — the remaining leak after bd-sw4xy1vw covered the automerge and quarto-hub databases. cache.js now checks a runtime global (__Q2_EPHEMERAL_STORAGE__) and serves the same API from a module-level Map with identical LRU semantics (touch-on-read, same 200-entry/50MB eviction). A runtime global rather than a build-time import.meta.env read keeps the module byte-identical across builds — the preview embed's asset dedupe and live-share manifest hashes depend on the WASM glue chunk not diverging (verified: all three .wasm binaries and the hub-client glue chunk remain hash-identical across the viewer/editor dists). The flag is set by the only two realms that run the WASM: hub-client's main.tsx (gated on isEphemeralStorage(), i.e. the preview-embed build) and q2-preview-spa's main.tsx (unconditional — the viewer only ever runs inside ephemeral q2 preview sessions, matching its hardcoded storage: 'memory'). The q2-preview renderer iframe never runs the WASM and needs nothing. Tests: 9 new memory-mode tests in cache.memory.test.ts run without fake-indexeddb, so any fall-through to the persistent path throws; the existing IndexedDB suite passes unchanged. E2E (real Chromium, q2 preview on a single-page fixture): editor mode — indexedDB.databases() empty after boot and after reload, reload still recovers the session via editorBoot; viewer mode — empty after boot and after reload. wasm-js-bridge, q2-preview-spa, hub-client (unit + integration + wasm) suites and cargo xtask verify all green.
What this adds
q2 previewcan now share a running preview session with other people.q2 preview --share index.qmdprints a join string.q2 preview --join <string>and opens the printed link. They see the same preview, live.--ui editoropens the full hub-client editor instead of the viewer, for the host and guests alike.--browser <name>opens the session in a specific browser instead of the system default. It works for the host,--share, and--joinalike, and conflicts with--no-browser.Who can do what
--allow-edit. The share banner says exactly what the join string grants.--ui editorwithout--allow-editallows "ephemeral" editing: edits sync live to everyone in the session but never write to disk. The editor shows a persistent "Ephemeral session — edits won't be saved to disk" banner in this mode, for guests too (driven by/api/preview/config), so session-only edits cannot be missed.First-join payload
A guest always has the q2 binary installed, and that binary embeds the same preview UI bundles that the host serves. This branch uses that to cut the first-join download from the whole UI to a few kilobytes:
/api/preview/config. The guest compares it against its own during join setup, before the local port binds. On an exact match, the guest serves the UI from its own binary, and only the session's dynamic traffic (/ws,/api/*,/auth/*,/health) crosses the tunnel. On any mismatch (an older or newer q2, a dev build), everything tunnels as before: mixed-version joins still work, they only boot slower.wasm-opt -Oz). The embedded UI bundles now ship as identity-only tar.zst archives (~107 MiB → 17.0 MiB embedded), decompressed lazily on first asset request —q2 rendernever pays. Gzip responses are generated at runtime (level 9, cached per file) whenAccept-Encodingallows, producing the same wire bytes the precompressed siblings did (~3.8× on the viewer dist). Content-hashed/assets/*now carryimmutablecache headers. Theq2binary is 41.6 MB smaller net from the WASM/dedupe work, and the archive embed takes a local release build from 193.7 → 106.6 MiB on top (−87.0 MiB, −44.9%).--share/--joinbinaries with a headless-Chromium boot driver. Wire payload: 54.67 MB → ~3 KB tunneled, plus ~10.8 MB served from the guest's own binary. First render on a simulated 10 Mbps / 100 ms link: 48.0 s → 1.5 s (9.9 s for the all-tunnel fallback on the identical topology).How it works
quarto-p2pcrate provides the share tickets, the tunnel host (in front of the preview server's loopback port), and the tunnel client. The client (TunnelClient::connect+bind) serves a local proxy for the guest. All traffic goes over one encrypted iroh connection. If no relay is reachable, the banner says that guests can join over LAN/direct connections only.--joinruns a small L7 frontend on its local proxy port. A bounded head-peek (64 KiB / 5 s) routes each connection. The frontend answers exact manifest-hitGET/HEADrequests from the guest's own embedded bundle (byte-identical bytes and headers via the sharedasset_responsebuilder) and splices everything else onto the tunnel.Connection: closeapplies both ways: on local responses, and forced into tunneled non-upgrade heads. A browser cannot mix local and tunneled requests on one keep-alive connection. WebSocket upgrades pass byte-identical. There is deliberately no local index fallback: unknown paths always tunnel, so the host stays the single authority on what is dynamic. On a mismatch,--joinbypasses the frontend entirely and acts as the plain thin proxy: the host serves everything through the tunnel. Host and guests share one Automerge document set either way./api/preview/config, so guests land in the same document instead of a setup screen.Fixes included
--allow-edit, the hub's 5-second disk sync deleted any edit older than the previous sync tick, on the host and guests alike. The sync now skips its disk-merge step when the file on disk has not changed. Regression test included.--allow-edit: previously, a guest's new file (New File dialog) or uploaded asset stayed VFS-only. The Automerge doc and index entry synced to the hub, but the hub skipped the missing disk file on every sync tick, so the file never landed on disk — contradicting the share banner's "EDIT the project's files on disk". The hub now distinguishes the two missing-file cases by sync checkpoint. No checkpoint means VFS-created: the hub writes the file to disk at the next sync tick (text and binary, parent directories created, symlink-escape paths rejected). A checkpoint means deleted on disk: the file keeps skipping and is never resurrected. Read-only sessions are unchanged. Six new hub sync tests cover this, plus an end-to-end run through the real--share/--joinbinaries with a browser-driven guest (create, upload, host edit back, delete stays deleted).automergedocument cache and thequarto-hubproject/identity/project-set records — were never read again and accumulated unboundedly across sessions. The preview-embed build now runs withVITE_EPHEMERAL_STORAGE=1: the automerge repos use the in-memory storage adapter (the same one the viewer SPA uses) and the hub records live in an in-memory facade behind the existing storage seam, so nothing persists per session. Reloading the editor rebuilds the session from the/api/preview/configeditorBootparams instead of landing on "Project not found". This also retires the project-set migration-screen wart on fresh origins. Thequarto-cacheWASM artifact cache (SASS/theme artifacts) gets the same treatment: when the session is ephemeral it serves from an in-memory store with identical LRU semantics, in the viewer and editor alike — preview sessions leave no IndexedDB databases behind at all.fork_atcollector panic (pinned fork + panic containment). That commit is gone: the branch is rebased onto main, and the samod 0.13.0 / automerge 3.4.1 bump (Bump samod to 0.13.0 and automerge to 3.4.1 #513) fixes the panic upstream.Testing
editorBootconfig parsing; the WASM bridge cache's in-memory mode has its own suite mirroring the IndexedDB contract (both run with noindexedDBglobal, so any leak into real IndexedDB throws). The ephemeral-storage behavior was verified end-to-end with real Chromium sessions againstq2 previewin both editor and viewer modes:indexedDB.databases()is completely empty after boot and after a page reload, and the editor reload recovers the session.TunnelClient::connectround-trip and token rejection, and 10 join-frontend tests (local serving with zero host asset requests, full-tunnel fallback, unknown-path tunneling, WS splice survival, 431 on oversize head, head-peek timeout, HEAD semantics, editor-UI boot).--share/--joinbinaries: the guest serves all 12 boot asset requests locally, and only/health,/api/*, and/wscross the tunnel (~3 KB). This e2e caught a bug that the per-connection tests could not: Chromium reuses idle tunneled keep-alive connections for later asset requests (~8.7 MB crossed the tunnel unrouted). The fix forcesConnection: closeinto tunneled non-upgrade heads. Regression tests included..gzsiblings in the binary), the runtime-gzip skip set sharing one source of truth withprecompress-dist.mjs(scripts/gzip-skip-extensions.txt), and the gz→identity roundtrip; the existing asset-serving HTTP contract tests pass unchanged against the runtime-generated bytes.cargo xtask verifyis green, including the hub-client build and WASM tests.--joinhelp text mentions--browserand documents the embedded-asset serving and mismatch fallback.share.rstunnel tests sometimes exceeded their 20s timeouts on the loaded ubuntu runner. They now run multi-threaded like every other tunnel test, with 60s caps.