WAI dev and the relay close a WebSocket cleanly; Quantum Studio opens its course from a link #533

Closed
dcharlot wants to merge 4 commits from wai/relay-ws-close-drain into main
10 changed files with 88 additions and 17 deletions

View file

@ -30,6 +30,9 @@ use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use tokio::sync::{mpsc, watch}; 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. /// The sending end of a message pipe.
#[async_trait] #[async_trait]
pub trait PipeTx: Send + 'static { pub trait PipeTx: Send + 'static {
@ -629,14 +632,19 @@ impl MuxConnection {
}, },
Ok(None) => { Ok(None) => {
r.set_closed(TransportError::Closed { code: 0, reason: "the pipe closed".into(), by_peer: true }); r.set_closed(TransportError::Closed { code: 0, reason: "the pipe closed".into(), by_peer: true });
break; return;
} }
Err(e) => { Err(e) => {
r.set_closed(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 } MuxConnection { inner, target: None }
} }

View file

@ -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. 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=<lesson>` opens it at that lesson (`#course=bell`); it goes with `#qasm=…`.
## Languages ## Languages
The interface's words are data: `locales/<locale>/<area>.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. The interface's words are data: `locales/<locale>/<area>.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; - 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 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; - 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 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. - the layout at a phone's width.

View file

@ -38,6 +38,8 @@ Ctrl+Enter(macOS 上为 ⌘+Enter)运行程序,Escape 取消运行。程
这些都不会显示答案。进度(哪些课已通过、检查和提示各用了多少次)只保存在这个浏览器中,窗格上也如此注明。旁边链接了 *Quantum, reconsidered*,一门关于干涉与学习的课程。 这些都不会显示答案。进度(哪些课已通过、检查和提示各用了多少次)只保存在这个浏览器中,窗格上也如此注明。旁边链接了 *Quantum, reconsidered*,一门关于干涉与学习的课程。
链接也可以打开它:页面地址中的 `#course` 打开课程窗格,`#course=<lesson>` 则打开到那一课(`#course=bell`);它可以与 `#qasm=…` 一起使用。
## 语言 ## 语言
界面上的文字是数据:`locales/<locale>/<area>.json`,界面的每个部分一个文件,英文为源文本,简体中文与之并列(由 `locales/index.js` 汇集)。在你用页眉中的切换开关选定一种语言之前,Studio 跟随浏览器的语言;你的选择保存在这个浏览器中。宿主页面可以在挂载元素上用 `data-lang` 固定一种语言。引擎自身的消息(判定的陈述、诊断)保持引擎写出时的原样。中文文字为机器辅助翻译,在每个文件和页脚中均已注明,尚待熟练的读者审校。`node test/locales.test.mjs` 要求每种语言都具备每一个键,且占位符相同。 界面上的文字是数据:`locales/<locale>/<area>.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 的线路图; - 用引擎的措辞描述的线路,以及保存为 SVG 和 LaTeX 的线路图;
- 英文的课程:载入并检查一课的起始程序,哪里出了问题,附带引擎指引的提示,答案通过并保存在浏览器中,一条规则和一个生成器的各种情况各自附有判定; - 英文的课程:载入并检查一课的起始程序,哪里出了问题,附带引擎指引的提示,答案通过并保存在浏览器中,一条规则和一个生成器的各种情况各自附有判定;
- 课程链接(`#course=bell`)打开到那一课;
- 根据浏览器语言显示的中文课程,切换到英文再切换回来,以及打开课程时在手机宽度下的布局; - 根据浏览器语言显示的中文课程,切换到英文再切换回来,以及打开课程时在手机宽度下的布局;
- 手机宽度下的布局。 - 手机宽度下的布局。

View file

@ -267,9 +267,15 @@ export function coursePane({ engine, program, load, ask, status }) {
} catch (e) { say(e.message, 'bad'); } } catch (e) { say(e.message, 'bad'); }
} }
button.addEventListener('click', () => open()); button.addEventListener('click', () => open());
let reopen = false; // `#course` in the page's address opens the pane (a link from the course page), and
try { reopen = localStorage.getItem(OPEN) === '1'; } catch (e) {} // `#course=<lesson>` opens it at that lesson; otherwise it reopens as it was left.
if (reopen) open(true); 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 }; return { node, button, open, show, check, store, current: () => lesson };
} }

View file

@ -141,7 +141,7 @@ const settles = (page, ...res) => page.waitForFunction((srcs) => {
}, res.map((r) => r.source), { timeout: LONG }).then(() => true, () => false); }, res.map((r) => r.source), { timeout: LONG }).then(() => true, () => false);
const browser = await chromium.launch({ channel: process.env.WAI_E2E_CHANNEL ?? 'chrome', headless: true }); 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 ctx = await browser.newContext({ viewport, colorScheme: 'dark', locale });
const page = await ctx.newPage(); const page = await ctx.newPage();
const seen = { errors: [], failed: [] }; 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('pageerror', (e) => seen.errors.push(String(e)));
page.on('requestfailed', (r) => seen.failed.push(`${r.url()} (${r.failure()?.errorText})`)); 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()})`); }); 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 }; 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 }); 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.fill('#qs-shots', '321');
await page.click('.qs-editor .cm-content'); await page.click('.qs-editor .cm-content');
await page.keyboard.press('ControlOrMeta+Enter'); 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); 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()); const program = await page.evaluate(() => window.quantumStudio.view.state.doc.toString());
await page.reload({ waitUntil: 'domcontentloaded' }); await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => /qubits/.test(document.querySelector('#qs-lint')?.textContent ?? ''), null, { timeout: LONG }); 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(); await ctx.close();
// ---- the course ---- // ---- the course ----
// A link to it: `#course=<lesson>` 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 COURSE = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', 'wai-quantum', 'course', 'lessons');
const exercise = (id) => JSON.parse(readFileSync(join(COURSE, id, 'exercise.json'), 'utf8')); const exercise = (id) => JSON.parse(readFileSync(join(COURSE, id, 'exercise.json'), 'utf8'));
const lessonProgram = (v) => (Array.isArray(v) ? v.join('\n') + '\n' : v); const lessonProgram = (v) => (Array.isArray(v) ? v.join('\n') + '\n' : v);

View file

@ -5822,5 +5822,8 @@ Options:
## Pages ## Pages
- [The console](https://wai.transaction.science/quantum-run/): the service from a browser, or the same engine in the tab. - [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. - [Source](https://git.transaction.science/Transaction-Science/open-standards): the engine, the command line and the service.

View file

@ -137,7 +137,10 @@ Prompts:
## Pages ## Pages
- [The console](https://wai.transaction.science/quantum-run/): the service from a browser, or the same engine in the tab. - [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. - [Source](https://git.transaction.science/Transaction-Science/open-standards): the engine, the command line and the service.
## Optional ## Optional

View file

@ -447,7 +447,10 @@ fn links() -> String {
## Pages ## Pages
- [The console]({SITE}/quantum-run/): the service from a browser, or the same engine in the tab. - [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. - [Source]({SOURCE}): the engine, the command line and the service.
" "
) )

View file

@ -543,11 +543,14 @@ mod tests {
e(&mut forged); e(&mut forged);
assert_eq!(verify(&forged)["valid"], false, "edit {k}"); 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 again = run(&set, 1 << 30, &key(), json!({ "unmetered": "a test" })).unwrap();
let strip = |b: &Value| { let strip = |b: &Value| {
let mut b = b["result"]["items"].clone(); let mut b = b["result"]["items"].clone();
for it in b.as_array_mut().unwrap() { 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("elapsed_ms");
it["result"].as_object_mut().unwrap().remove("receipt"); it["result"].as_object_mut().unwrap().remove("receipt");
} }

View file

@ -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(format!("{head}\r\n").as_bytes())?;
up.write_all(rest)?; up.write_all(rest)?;
c.set_read_timeout(None)?; // Each side's end goes on as a half-close (a FIN), and the client's last bytes after the
let (mut c2, mut up2) = (c.try_clone()?, up.try_clone()?); // 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 t = std::thread::spawn(move || {
let _ = std::io::copy(&mut up2, &mut c2); 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 (mut buf, mut relay_open, mut since) = (vec![0u8; 16 * 1024], true, None::<Instant>);
let _ = up.shutdown(Shutdown::Both); 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(); let _ = t.join();
Ok(()) Ok(())
} }