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 } } 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..b66db20f4 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 }); @@ -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 }); @@ -1189,6 +1192,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. " ) 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"); } 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(()) }