From 9f39ba863de874450a122f94fd101d2c5c4fa67f Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Thu, 29 Oct 2026 06:53:30 -0400 Subject: [PATCH 1/4] Quantum Studio: #course opens the circuits course; llms.txt lists the site's pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Quantum Studio: `#course` in the page's address opens the Course pane, and `#course=` opens it at that lesson; it goes with `#qasm=…`. A test opens `/#course=bell` and finds the pane open at the Bell lesson, with no console error. Both READMEs say so. - The generated llms.txt (and llms-full.txt) list the quantum IDE, the interference course and WAI's home beside the console and Quantum Studio, and say the Studio's course opens from `#course`. The site's sync no longer needs to add them. Co-Authored-By: Claude Opus 5.5 --- wai/quantum-studio/README.md | 3 +++ wai/quantum-studio/README.zh-CN.md | 3 +++ wai/quantum-studio/src/course.js | 12 +++++++++--- wai/quantum-studio/test/studio.test.mjs | 12 ++++++++++-- wai/wai-quantum-cli/docs/llms-full.txt | 5 ++++- wai/wai-quantum-cli/docs/llms.txt | 5 ++++- wai/wai-quantum-cli/src/guide.rs | 5 ++++- 7 files changed, 37 insertions(+), 8 deletions(-) diff --git a/wai/quantum-studio/README.md b/wai/quantum-studio/README.md index 2f8e92977..2cca6000e 100644 --- a/wai/quantum-studio/README.md +++ b/wai/quantum-studio/README.md @@ -34,6 +34,8 @@ Ctrl+Enter (⌘+Enter on macOS) runs the program, and Escape cancels a run. The None of these shows the solution. Progress (which lessons passed, how many checks and hints) is kept only in this browser, and the pane says so. *Quantum, reconsidered*, the course on interference and learning, is linked beside it. +A link can open it: `#course` in the page's address opens the pane, and `#course=` opens it at that lesson (`#course=bell`); it goes with `#qasm=…`. + ## Languages The interface's words are data: `locales//.json`, one file for each part of the interface, English the source and Simplified Chinese beside it (`locales/index.js` gathers them). The Studio follows the browser's language until you pick one with the switch in the header; your choice is kept in this browser. A host may pin one with `data-lang` on the mount element. The engine's own messages (a verdict's statement, a diagnostic) stay as the engine writes them. The Chinese words are a machine-assisted translation, marked so in each file and in the footer, to be reviewed by a fluent reader. `node test/locales.test.mjs` holds every locale to every key, with the same placeholders. @@ -264,6 +266,7 @@ The test runs the Studio in the installed browser: - editing by the drawing: a gate dropped from the palette onto a wire, one dragged to another wire, moved and deleted from the keyboard, each recorded as your edit, and a gate of a broadcast refused with its line; - the circuit described in the engine's words, and the drawing saved as SVG and as LaTeX; - the course, in English: a lesson's starter loaded and checked, what went wrong, a hint with its pointer, the solution passed and kept in the browser, a rule and a generator's cases each with their verdict; +- a link to the course (`#course=bell`) opening the pane at that lesson; - the course in Chinese from the browser's language, the switch to English and back, and the layout at a phone's width with the course open; - the layout at a phone's width. diff --git a/wai/quantum-studio/README.zh-CN.md b/wai/quantum-studio/README.zh-CN.md index d4d31aa72..647542f65 100644 --- a/wai/quantum-studio/README.zh-CN.md +++ b/wai/quantum-studio/README.zh-CN.md @@ -38,6 +38,8 @@ Ctrl+Enter(macOS 上为 ⌘+Enter)运行程序,Escape 取消运行。程 这些都不会显示答案。进度(哪些课已通过、检查和提示各用了多少次)只保存在这个浏览器中,窗格上也如此注明。旁边链接了 *Quantum, reconsidered*,一门关于干涉与学习的课程。 +链接也可以打开它:页面地址中的 `#course` 打开课程窗格,`#course=` 则打开到那一课(`#course=bell`);它可以与 `#qasm=…` 一起使用。 + ## 语言 界面上的文字是数据:`locales//.json`,界面的每个部分一个文件,英文为源文本,简体中文与之并列(由 `locales/index.js` 汇集)。在你用页眉中的切换开关选定一种语言之前,Studio 跟随浏览器的语言;你的选择保存在这个浏览器中。宿主页面可以在挂载元素上用 `data-lang` 固定一种语言。引擎自身的消息(判定的陈述、诊断)保持引擎写出时的原样。中文文字为机器辅助翻译,在每个文件和页脚中均已注明,尚待熟练的读者审校。`node test/locales.test.mjs` 要求每种语言都具备每一个键,且占位符相同。 @@ -234,6 +236,7 @@ node build.mjs --pkg PATH/TO/PKG && node test/studio.test.mjs - 通过线路图编辑:从门面板拖放到某条线上的门,拖到另一条线上的门,用键盘移动和删除的门,每一项都记录为你的编辑,以及广播中的一个门被拒绝并给出其所在行; - 用引擎的措辞描述的线路,以及保存为 SVG 和 LaTeX 的线路图; - 英文的课程:载入并检查一课的起始程序,哪里出了问题,附带引擎指引的提示,答案通过并保存在浏览器中,一条规则和一个生成器的各种情况各自附有判定; +- 课程链接(`#course=bell`)打开到那一课; - 根据浏览器语言显示的中文课程,切换到英文再切换回来,以及打开课程时在手机宽度下的布局; - 手机宽度下的布局。 diff --git a/wai/quantum-studio/src/course.js b/wai/quantum-studio/src/course.js index 2029f2633..85f73b2e0 100644 --- a/wai/quantum-studio/src/course.js +++ b/wai/quantum-studio/src/course.js @@ -267,9 +267,15 @@ export function coursePane({ engine, program, load, ask, status }) { } catch (e) { say(e.message, 'bad'); } } button.addEventListener('click', () => open()); - let reopen = false; - try { reopen = localStorage.getItem(OPEN) === '1'; } catch (e) {} - if (reopen) open(true); + // `#course` in the page's address opens the pane (a link from the course page), and + // `#course=` opens it at that lesson; otherwise it reopens as it was left. + const linked = new URLSearchParams(location.hash.slice(1)); + let reopen = linked.has('course'); + if (!reopen) try { reopen = localStorage.getItem(OPEN) === '1'; } catch (e) {} + if (reopen) open(true).then(() => { + const id = linked.get('course'); + if (id && course?.units.some((u) => u.lessons.some((l) => l.id === id))) return show(id); + }); return { node, button, open, show, check, store, current: () => lesson }; } diff --git a/wai/quantum-studio/test/studio.test.mjs b/wai/quantum-studio/test/studio.test.mjs index 08d8e4487..5c217aa94 100644 --- a/wai/quantum-studio/test/studio.test.mjs +++ b/wai/quantum-studio/test/studio.test.mjs @@ -141,7 +141,7 @@ const settles = (page, ...res) => page.waitForFunction((srcs) => { }, res.map((r) => r.source), { timeout: LONG }).then(() => true, () => false); const browser = await chromium.launch({ channel: process.env.WAI_E2E_CHANNEL ?? 'chrome', headless: true }); -async function open(viewport = { width: 1400, height: 950 }, locale = 'en-US') { +async function open(viewport = { width: 1400, height: 950 }, locale = 'en-US', path = '/') { const ctx = await browser.newContext({ viewport, colorScheme: 'dark', locale }); const page = await ctx.newPage(); const seen = { errors: [], failed: [] }; @@ -149,7 +149,7 @@ async function open(viewport = { width: 1400, height: 950 }, locale = 'en-US') { page.on('pageerror', (e) => seen.errors.push(String(e))); page.on('requestfailed', (r) => seen.failed.push(`${r.url()} (${r.failure()?.errorText})`)); page.on('response', (r) => { if (r.status() >= 400) seen.failed.push(`${r.url()} (${r.status()})`); }); - await page.goto(ORIGIN + '/', { waitUntil: 'domcontentloaded' }); + await page.goto(ORIGIN + path, { waitUntil: 'domcontentloaded' }); return { ctx, page, seen }; } const done = (page) => page.waitForFunction(() => !/^Running|^On the service/.test(document.querySelector('.qs-status')?.textContent ?? '') && document.querySelector('#qs-run') && !document.querySelector('#qs-run').disabled, null, { timeout: LONG }); @@ -1189,6 +1189,14 @@ check(seen.failed.length === 0, `no failed request${seen.failed.length ? ': ' + await ctx.close(); // ---- the course ---- +// A link to it: `#course=` opens the pane at that lesson. +{ + const { ctx, page, seen } = await open(undefined, 'en-US', '/#course=bell'); + const at = await page.waitForFunction(() => !document.querySelector('#qs-course')?.hidden && document.querySelector('#qs-course-title')?.textContent === 'Two qubits, one answer', null, { timeout: LONG }).then(() => true, () => false); + check(at && (await page.getAttribute('#qs-course-open', 'aria-expanded')) === 'true', `a link opens the course at its lesson (#course=bell: ${await page.textContent('#qs-course-title')})`); + check(seen.errors.length === 0, `the course link: no console error${seen.errors.length ? ': ' + seen.errors.join(' | ') : ''}`); + await ctx.close(); +} const COURSE = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', 'wai-quantum', 'course', 'lessons'); const exercise = (id) => JSON.parse(readFileSync(join(COURSE, id, 'exercise.json'), 'utf8')); const lessonProgram = (v) => (Array.isArray(v) ? v.join('\n') + '\n' : v); diff --git a/wai/wai-quantum-cli/docs/llms-full.txt b/wai/wai-quantum-cli/docs/llms-full.txt index dbaaa8c94..c161abaf7 100644 --- a/wai/wai-quantum-cli/docs/llms-full.txt +++ b/wai/wai-quantum-cli/docs/llms-full.txt @@ -5822,5 +5822,8 @@ Options: ## Pages - [The console](https://wai.transaction.science/quantum-run/): the service from a browser, or the same engine in the tab. -- [Quantum Studio](https://wai.transaction.science/quantum-studio/): an editor that checks a program as it is typed and runs it in the tab or on the service. +- [Quantum Studio](https://wai.transaction.science/quantum-studio/): an editor that checks a program as it is typed and runs it in the tab or on the service; its circuits course opens from `#course`. +- [The quantum IDE](https://wai.transaction.science/quantum-ide/): build circuits in the browser tab and inspect them. +- [Quantum algorithmic design](https://wai.transaction.science/quantum-learn/): a course on designing with interference on classical hardware, each lab run in the tab. +- [WAI](https://wai.transaction.science/): the media transport and execution standard this quantum stack is part of. - [Source](https://git.transaction.science/Transaction-Science/open-standards): the engine, the command line and the service. diff --git a/wai/wai-quantum-cli/docs/llms.txt b/wai/wai-quantum-cli/docs/llms.txt index 29c64fad3..6b1f92e4b 100644 --- a/wai/wai-quantum-cli/docs/llms.txt +++ b/wai/wai-quantum-cli/docs/llms.txt @@ -137,7 +137,10 @@ Prompts: ## Pages - [The console](https://wai.transaction.science/quantum-run/): the service from a browser, or the same engine in the tab. -- [Quantum Studio](https://wai.transaction.science/quantum-studio/): an editor that checks a program as it is typed and runs it in the tab or on the service. +- [Quantum Studio](https://wai.transaction.science/quantum-studio/): an editor that checks a program as it is typed and runs it in the tab or on the service; its circuits course opens from `#course`. +- [The quantum IDE](https://wai.transaction.science/quantum-ide/): build circuits in the browser tab and inspect them. +- [Quantum algorithmic design](https://wai.transaction.science/quantum-learn/): a course on designing with interference on classical hardware, each lab run in the tab. +- [WAI](https://wai.transaction.science/): the media transport and execution standard this quantum stack is part of. - [Source](https://git.transaction.science/Transaction-Science/open-standards): the engine, the command line and the service. ## Optional diff --git a/wai/wai-quantum-cli/src/guide.rs b/wai/wai-quantum-cli/src/guide.rs index df56aa0de..3c814b610 100644 --- a/wai/wai-quantum-cli/src/guide.rs +++ b/wai/wai-quantum-cli/src/guide.rs @@ -447,7 +447,10 @@ fn links() -> String { ## Pages - [The console]({SITE}/quantum-run/): the service from a browser, or the same engine in the tab. -- [Quantum Studio]({SITE}/quantum-studio/): an editor that checks a program as it is typed and runs it in the tab or on the service. +- [Quantum Studio]({SITE}/quantum-studio/): an editor that checks a program as it is typed and runs it in the tab or on the service; its circuits course opens from `#course`. +- [The quantum IDE]({SITE}/quantum-ide/): build circuits in the browser tab and inspect them. +- [Quantum algorithmic design]({SITE}/quantum-learn/): a course on designing with interference on classical hardware, each lab run in the tab. +- [WAI]({SITE}/): the media transport and execution standard this quantum stack is part of. - [Source]({SOURCE}): the engine, the command line and the service. " ) -- 2.54.0 (Apple Git-157) From 193e99a85cb67419c4d01d16e6a71f79a1e34034 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Thu, 29 Oct 2026 06:57:24 -0400 Subject: [PATCH 2/4] joule-moq-relay: a closed session's pipe is read until the peer ends it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a session closed (the peer's WT_CLOSE_SESSION, or this end's), the mux's reader stopped reading at once; the writer then sent its Close and the socket went. A browser sends its WebSocket Close right after its CLOSE_SESSION, so that frame was still unread when the socket was shut, and a socket shut with unread bytes is reset rather than closed. WebKit reports the reset on the console ("WebSocket connection to ws://…/moq failed: Socket is not connected"), which failed WAI Studio's "no console error" check in WebKit on macOS after its pre-call test (which opens the relay's binding, waits for SETUP and closes). Counted per step: no error after leaving the room, one after the pre-call test. The reader now reads on after the close, discarding, until the peer ends the pipe or for at most 2 s, so the closing handshake finishes and the socket closes cleanly. Co-Authored-By: Claude Opus 5.5 --- .../crates/joule-moq-relay/src/transport/mux.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/src/transport/mux.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/src/transport/mux.rs index 545c6139b..50bc9e846 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/src/transport/mux.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/src/transport/mux.rs @@ -30,6 +30,9 @@ use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; use tokio::sync::{mpsc, watch}; +/// How long a closed session's pipe is read on for the peer's end of it. +const DRAIN: std::time::Duration = std::time::Duration::from_secs(2); + /// The sending end of a message pipe. #[async_trait] pub trait PipeTx: Send + 'static { @@ -629,14 +632,19 @@ impl MuxConnection { }, Ok(None) => { r.set_closed(TransportError::Closed { code: 0, reason: "the pipe closed".into(), by_peer: true }); - break; + return; } Err(e) => { r.set_closed(e); - break; + return; } } } + // The session closed while the pipe is open: read on, discarding, until the peer ends + // it (a WebSocket peer's Close) or for at most DRAIN. A socket shut with the peer's + // bytes still unread is reset, not closed (a WebSocket closed this way never finishes + // its closing handshake, and WebKit reports "Socket is not connected"). + let _ = tokio::time::timeout(DRAIN, async { while let Ok(Some(_)) = rx.recv().await {} }).await; }); MuxConnection { inner, target: None } } -- 2.54.0 (Apple Git-157) From bf213ebfc7fd9e704d02c2eb58d1d28503e5071e Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Thu, 29 Oct 2026 07:01:59 -0400 Subject: [PATCH 3/4] wai dev: the relay's tunnel half-closes and reads the client's last bytes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wai dev's origin tunnels /moq to the relay. When the relay ended its side, the tunnel shut the client's socket both ways at once (and the relay's when the client ended first). A browser's WebSocket Close, sent right after its CLOSE_SESSION, was then still unread, and a socket shut with the peer's bytes unread is reset, not closed. WebKit reports the reset ("WebSocket connection to ws://…/moq failed: Socket is not connected"): WAI Studio's "no console error" check failed in WebKit on macOS after its pre-call test (no error after leaving the room, one after the pre-call test). Each side's end now goes on as a half-close. After the relay's end the tunnel reads on (discarding what can no longer go to the relay) until the client ends its side, or for at most 2 s. A relay that does not end its side after the client has gone is shut after 5 s. Co-Authored-By: Claude Opus 5.5 --- wai/wai-rs/src/bin/wai_cli/dx/dev.rs | 38 ++++++++++++++++++++++++---- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/wai/wai-rs/src/bin/wai_cli/dx/dev.rs b/wai/wai-rs/src/bin/wai_cli/dx/dev.rs index 8850b43f2..53ac52e38 100644 --- a/wai/wai-rs/src/bin/wai_cli/dx/dev.rs +++ b/wai/wai-rs/src/bin/wai_cli/dx/dev.rs @@ -997,14 +997,42 @@ fn tunnel(mut c: TcpStream, h: &Head, rest: &[u8], to: SocketAddr) -> std::io::R } up.write_all(format!("{head}\r\n").as_bytes())?; up.write_all(rest)?; - c.set_read_timeout(None)?; - let (mut c2, mut up2) = (c.try_clone()?, up.try_clone()?); + // Each side's end goes on as a half-close (a FIN), and the client's last bytes after the + // relay's end are still read: a socket shut with the peer's bytes unread is reset, not + // closed, so a WebSocket closed through the tunnel never finished its closing handshake + // (WebKit reported "Socket is not connected"). Reads wake each second to see whether the + // relay has gone, and the client gets 2 s after that to end its side. + c.set_read_timeout(Some(Duration::from_secs(1)))?; + let gone = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let (mut c2, mut up2, g) = (c.try_clone()?, up.try_clone()?, gone.clone()); + let (done_tx, done_rx) = std::sync::mpsc::channel::<()>(); let t = std::thread::spawn(move || { let _ = std::io::copy(&mut up2, &mut c2); - let _ = c2.shutdown(Shutdown::Both); + g.store(true, std::sync::atomic::Ordering::Release); + let _ = c2.shutdown(Shutdown::Write); + let _ = done_tx.send(()); }); - let _ = std::io::copy(&mut c, &mut up); - let _ = up.shutdown(Shutdown::Both); + let (mut buf, mut relay_open, mut since) = (vec![0u8; 16 * 1024], true, None::); + loop { + match c.read(&mut buf) { + Ok(0) => break, + Ok(n) => { + if relay_open && up.write_all(&buf[..n]).is_err() { + relay_open = false; + } + } + Err(e) if matches!(e.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut | std::io::ErrorKind::Interrupted) => {} + Err(_) => break, + } + if gone.load(std::sync::atomic::Ordering::Acquire) && since.get_or_insert_with(Instant::now).elapsed() >= Duration::from_secs(2) { + break; + } + } + let _ = up.shutdown(Shutdown::Write); + // A relay that does not end its side once the client has gone is shut after 5 s. + if done_rx.recv_timeout(Duration::from_secs(5)).is_err() { + let _ = up.shutdown(Shutdown::Both); + } let _ = t.join(); Ok(()) } -- 2.54.0 (Apple Git-157) From 36f1562e066b514950cc1369a3c168dcd3e888bf Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Thu, 29 Oct 2026 08:18:57 -0400 Subject: [PATCH 4/4] Two tests held to what they test: a set's items less their paths, Ctrl+Enter by its entry - wai-quantum engine::sets: "the same set runs to the same bytes, less the wall clock" stripped each item's elapsed_ms and receipt but kept its Merkle path. A path's sibling leaves digest the other items' results, elapsed_ms included, so two runs whose items took different times gave different paths, as on a loaded linux runner (#533, sets.rs:556: the second path hash differed). The path goes too; verify() still checks it against the root. - Quantum Studio's browser test: after Ctrl+Enter it waited for the run button and an idle status, which can both hold before the run has begun, and then counted the history. It now waits for the history's new entry. Co-Authored-By: Claude Opus 5.5 --- wai/quantum-studio/test/studio.test.mjs | 5 ++++- wai/wai-quantum/src/engine/sets.rs | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/wai/quantum-studio/test/studio.test.mjs b/wai/quantum-studio/test/studio.test.mjs index 5c217aa94..b66db20f4 100644 --- a/wai/quantum-studio/test/studio.test.mjs +++ b/wai/quantum-studio/test/studio.test.mjs @@ -280,8 +280,11 @@ const before = (await page.$$('.qs-hitem')).length; await page.fill('#qs-shots', '321'); await page.click('.qs-editor .cm-content'); await page.keyboard.press('ControlOrMeta+Enter'); +// The run starts after the key, so its entry is waited for: the run button can still be +// enabled, and the status not yet running, before the run has begun. +const ranByKey = await page.waitForFunction((n) => document.querySelectorAll('.qs-hitem').length === n + 1, before, { timeout: LONG }).then(() => true, () => false); await done(page); -check((await page.$$('.qs-hitem')).length === before + 1, 'Ctrl+Enter in the editor runs the program'); +check(ranByKey, 'Ctrl+Enter in the editor runs the program'); const program = await page.evaluate(() => window.quantumStudio.view.state.doc.toString()); await page.reload({ waitUntil: 'domcontentloaded' }); await page.waitForFunction(() => /qubits/.test(document.querySelector('#qs-lint')?.textContent ?? ''), null, { timeout: LONG }); diff --git a/wai/wai-quantum/src/engine/sets.rs b/wai/wai-quantum/src/engine/sets.rs index 3f8cfd20e..3275009cb 100644 --- a/wai/wai-quantum/src/engine/sets.rs +++ b/wai/wai-quantum/src/engine/sets.rs @@ -543,11 +543,14 @@ mod tests { e(&mut forged); assert_eq!(verify(&forged)["valid"], false, "edit {k}"); } - // The same set runs to the same bytes, less the wall clock. + // The same set runs to the same bytes, less the wall clock: each item's elapsed_ms, + // the receipt over it, and its path, whose sibling leaves digest the other items' + // results with their elapsed_ms. let again = run(&set, 1 << 30, &key(), json!({ "unmetered": "a test" })).unwrap(); let strip = |b: &Value| { let mut b = b["result"]["items"].clone(); for it in b.as_array_mut().unwrap() { + it.as_object_mut().unwrap().remove("path"); it["result"].as_object_mut().unwrap().remove("elapsed_ms"); it["result"].as_object_mut().unwrap().remove("receipt"); } -- 2.54.0 (Apple Git-157)