aboutsummaryrefslogtreecommitdiff
path: root/bundle
diff options
context:
space:
mode:
author_ov4 <ov4@ov4.org>2026-10-21 11:08:50 +0000
committer_ov4 <ov4@ov4.org>2026-10-21 11:08:50 +0000
commit7b94b9003daa1126ce78df37481fc52774c2f0b4 (patch)
treec5e491c2a33d031da2f3400143ec74dab437e291 /bundle
parent5a254e2ab4741bc88c373ae1673c2ad9a7e6a588 (diff)
feat(bundle): run the bundled compiler with input and streaming output
Save before running, close prefilled stdin at EOF, and stream output bytes through Tauri channels. Stop and reap the child before exit or another run. Disable embedded stdout buffering and exercise the runner with real programs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'bundle')
-rw-r--r--bundle/AGENTS/DEVELOPMENT.md4
-rw-r--r--bundle/index.html2
-rw-r--r--bundle/src-tauri/Cargo.lock34
-rw-r--r--bundle/src-tauri/Cargo.toml1
-rw-r--r--bundle/src-tauri/src/main.rs50
-rw-r--r--bundle/src-tauri/src/runner.rs267
-rw-r--r--bundle/src/main.js71
7 files changed, 423 insertions, 6 deletions
diff --git a/bundle/AGENTS/DEVELOPMENT.md b/bundle/AGENTS/DEVELOPMENT.md
index 543962a..36e87a2 100644
--- a/bundle/AGENTS/DEVELOPMENT.md
+++ b/bundle/AGENTS/DEVELOPMENT.md
@@ -18,4 +18,8 @@ The editor imports the plugin's grammar, language configuration and pure CommonJ
The language editing core (grammar, configuration, indentation, lexical masking and bracket matching) is shared in full. Platform editor API adapters, file dialogs and process management are separate. Keep the plugin free of Bundle runtime dependencies.
+The Rust runner launches the sibling `cpsc` executable directly, using the source directory as cwd. Tokio concurrently writes prefilled stdin and drains both output pipes; stdin is dropped after writing. A watch signal kills and reaps the process on Stop or application exit. The output Channel sends raw byte chunks (one streaming TextDecoder per stream in the frontend), then a finished event after both pipes reach EOF. EMBEDDED execution disables the host CRT stdout buffer before JIT initialization; Windows JIT lookups prefer the same msvcrt runtime. Do not introduce a shell or pseudo-terminal.
+
+After building the compiler, run `cargo test --release --manifest-path src-tauri/Cargo.toml`. The two native tests exercise real compilation, prefilled input, EOF, Unicode, diagnostics, output before process exit, stopping, single-run exclusion and restart, including source paths with spaces. CI runs them on all three native platforms.
+
Use brief English UI labels and messages. Keep the first version to one document and prefilled stdin. Before feature commits, run affected plugin tests, build the frontend and check native compilation as appropriate. Do not add a separate UI test framework for routine changes.
diff --git a/bundle/index.html b/bundle/index.html
index 7d47992..38739ea 100644
--- a/bundle/index.html
+++ b/bundle/index.html
@@ -10,7 +10,7 @@
<button id="new">New</button>
<button id="open">Open</button>
<button id="save">Save</button>
- <button id="run" disabled>Run</button>
+ <button id="run">Run</button>
<span id="filename">Untitled.cps</span>
</header>
<main>
diff --git a/bundle/src-tauri/Cargo.lock b/bundle/src-tauri/Cargo.lock
index 04193f7..7fa057e 100644
--- a/bundle/src-tauri/Cargo.lock
+++ b/bundle/src-tauri/Cargo.lock
@@ -396,6 +396,7 @@ dependencies = [
"tauri",
"tauri-build",
"tauri-plugin-dialog",
+ "tokio",
]
[[package]]
@@ -742,6 +743,16 @@ dependencies = [
]
[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys 0.60.2",
+]
+
+[[package]]
name = "fastrand"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2812,6 +2823,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
+name = "signal-hook-registry"
+version = "1.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
+dependencies = [
+ "errno",
+ "libc",
+]
+
+[[package]]
name = "simd-adler32"
version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3446,11 +3467,24 @@ dependencies = [
"libc",
"mio",
"pin-project-lite",
+ "signal-hook-registry",
"socket2",
+ "tokio-macros",
"windows-sys 0.61.2",
]
[[package]]
+name = "tokio-macros"
+version = "2.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+]
+
+[[package]]
name = "tokio-util"
version = "0.7.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/bundle/src-tauri/Cargo.toml b/bundle/src-tauri/Cargo.toml
index b1b9995..4a61998 100644
--- a/bundle/src-tauri/Cargo.toml
+++ b/bundle/src-tauri/Cargo.toml
@@ -11,6 +11,7 @@ tauri-build = { version = "2", features = [] }
tauri = { version = "2", features = [] }
tauri-plugin-dialog = "2"
serde = { version = "1", features = ["derive"] }
+tokio = { version = "1", features = ["process", "io-util", "sync", "macros", "rt-multi-thread", "time"] }
[profile.release]
strip = true
diff --git a/bundle/src-tauri/src/main.rs b/bundle/src-tauri/src/main.rs
index 34b1b70..96f040c 100644
--- a/bundle/src-tauri/src/main.rs
+++ b/bundle/src-tauri/src/main.rs
@@ -1,5 +1,9 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
+mod runner;
+use runner::{OutputEvent, Runner};
+use tauri::{ipc::Channel, Manager, State};
+
#[tauri::command]
fn read_file(path: String) -> Result<String, String> {
std::fs::read_to_string(path).map_err(|error| error.to_string())
@@ -10,10 +14,50 @@ fn write_file(path: String, text: String) -> Result<(), String> {
std::fs::write(path, text).map_err(|error| error.to_string())
}
+#[tauri::command]
+async fn run_file(
+ path: String,
+ input: String,
+ output: Channel<OutputEvent>,
+ runner: State<'_, Runner>,
+) -> Result<(), String> {
+ let executable = std::env::current_exe()
+ .map_err(|error| error.to_string())?
+ .with_file_name(if cfg!(windows) { "cpsc.exe" } else { "cpsc" });
+ let result = runner
+ .run(executable, path.into(), input, |stream, bytes| {
+ let _ = output.send(OutputEvent::Data {
+ stream: stream.into(),
+ bytes,
+ });
+ })
+ .await?;
+ output
+ .send(OutputEvent::Finished {
+ code: result.code,
+ stopped: result.stopped,
+ })
+ .map_err(|error| error.to_string())
+}
+
+#[tauri::command]
+async fn stop_run(runner: State<'_, Runner>) -> Result<(), String> {
+ runner.stop().await;
+ Ok(())
+}
+
fn main() {
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
- .invoke_handler(tauri::generate_handler![read_file, write_file])
- .run(tauri::generate_context!())
- .expect("Could not start CPSC Bundle");
+ .manage(Runner::default())
+ .invoke_handler(tauri::generate_handler![
+ read_file, write_file, run_file, stop_run
+ ])
+ .build(tauri::generate_context!())
+ .expect("Could not start CPSC Bundle")
+ .run(|app, event| {
+ if matches!(event, tauri::RunEvent::ExitRequested { .. }) {
+ tauri::async_runtime::block_on(app.state::<Runner>().stop());
+ }
+ });
}
diff --git a/bundle/src-tauri/src/runner.rs b/bundle/src-tauri/src/runner.rs
new file mode 100644
index 0000000..e2b492b
--- /dev/null
+++ b/bundle/src-tauri/src/runner.rs
@@ -0,0 +1,267 @@
+use serde::Serialize;
+use std::{
+ path::PathBuf,
+ process::Stdio,
+ sync::{Arc, Mutex},
+};
+use tokio::{
+ io::{AsyncRead, AsyncReadExt, AsyncWriteExt},
+ process::Command,
+ sync::watch,
+};
+
+#[derive(Clone, Default)]
+pub struct Runner(Arc<Mutex<Option<Control>>>);
+
+struct Control {
+ stop: watch::Sender<bool>,
+ done: watch::Receiver<bool>,
+}
+
+#[derive(Serialize)]
+#[serde(tag = "event", rename_all = "camelCase")]
+pub enum OutputEvent {
+ Data { stream: String, bytes: Vec<u8> },
+ Finished { code: Option<i32>, stopped: bool },
+}
+
+pub struct RunResult {
+ pub code: Option<i32>,
+ pub stopped: bool,
+}
+
+impl Runner {
+ pub async fn run(
+ &self,
+ executable: PathBuf,
+ source: PathBuf,
+ input: String,
+ output: impl Fn(&str, Vec<u8>) + Send + Sync,
+ ) -> Result<RunResult, String> {
+ let (stop, stop_rx) = watch::channel(false);
+ let (done_tx, done) = watch::channel(false);
+ {
+ let mut active = self.0.lock().unwrap();
+ if active.is_some() {
+ return Err("A program is already running.".into());
+ }
+ *active = Some(Control { stop, done });
+ }
+ let result = run_process(executable, source, input, stop_rx, output).await;
+ self.0.lock().unwrap().take();
+ let _ = done_tx.send(true);
+ result
+ }
+
+ pub async fn stop(&self) {
+ let control = self
+ .0
+ .lock()
+ .unwrap()
+ .as_ref()
+ .map(|run| (run.stop.clone(), run.done.clone()));
+ if let Some((stop, mut done)) = control {
+ let _ = stop.send(true);
+ while !*done.borrow_and_update() {
+ if done.changed().await.is_err() {
+ break;
+ }
+ }
+ }
+ }
+}
+
+async fn read_output(
+ mut pipe: impl AsyncRead + Unpin,
+ stream: &str,
+ output: &(impl Fn(&str, Vec<u8>) + Send + Sync),
+) -> Result<(), String> {
+ let mut buffer = [0; 8192];
+ loop {
+ let count = pipe
+ .read(&mut buffer)
+ .await
+ .map_err(|error| error.to_string())?;
+ if count == 0 {
+ return Ok(());
+ }
+ output(stream, buffer[..count].to_vec());
+ }
+}
+
+async fn run_process(
+ executable: PathBuf,
+ source: PathBuf,
+ input: String,
+ mut stop: watch::Receiver<bool>,
+ output: impl Fn(&str, Vec<u8>) + Send + Sync,
+) -> Result<RunResult, String> {
+ let mut command = Command::new(executable);
+ command
+ .arg(&source)
+ .arg("-r")
+ .current_dir(source.parent().ok_or("Invalid source path.")?)
+ .stdin(Stdio::piped())
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped())
+ .kill_on_drop(true);
+ #[cfg(windows)]
+ command.creation_flags(0x08000000); // CREATE_NO_WINDOW
+ let mut child = command
+ .spawn()
+ .map_err(|error| format!("Cannot start compiler: {error}"))?;
+ let mut stdin = child.stdin.take().unwrap();
+ let stdout = child.stdout.take().unwrap();
+ let stderr = child.stderr.take().unwrap();
+ let write_input = async move {
+ let result = stdin.write_all(input.as_bytes()).await;
+ drop(stdin); // EOF must reach programs even when Input is empty.
+ match result {
+ Err(error) if error.kind() != std::io::ErrorKind::BrokenPipe => Err(error.to_string()),
+ _ => Ok(()), // Programs may finish without consuming all input.
+ }
+ };
+ let wait = async {
+ tokio::select! {
+ status = child.wait() => status.map(|status| RunResult { code: status.code(), stopped: false }),
+ _ = stop.changed() => {
+ child.kill().await.map_err(|error| error.to_string())?;
+ child.wait().await.map(|status| RunResult { code: status.code(), stopped: true })
+ }
+ }.map_err(|error| error.to_string())
+ };
+ let (result, input_result, stdout_result, stderr_result) = tokio::join!(
+ wait,
+ write_input,
+ read_output(stdout, "stdout", &output),
+ read_output(stderr, "stderr", &output)
+ );
+ let result = result?;
+ input_result?;
+ stdout_result?;
+ stderr_result?;
+ Ok(result)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::sync::atomic::{AtomicUsize, Ordering};
+ use tokio::time::{timeout, Duration};
+
+ struct Source(PathBuf);
+ impl Source {
+ fn new(text: &str) -> Self {
+ static NEXT: AtomicUsize = AtomicUsize::new(0);
+ let directory = std::env::temp_dir().join(format!(
+ "cpsc bundle test {} {}",
+ std::process::id(),
+ NEXT.fetch_add(1, Ordering::Relaxed)
+ ));
+ std::fs::create_dir_all(&directory).unwrap();
+ let source = directory.join("program.cps");
+ std::fs::write(&source, text).unwrap();
+ Self(source)
+ }
+ }
+ impl Drop for Source {
+ fn drop(&mut self) {
+ let _ = std::fs::remove_dir_all(self.0.parent().unwrap());
+ }
+ }
+ fn compiler() -> PathBuf {
+ PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(if cfg!(windows) {
+ "../../build/cpsc.exe"
+ } else {
+ "../../build/cpsc"
+ })
+ }
+
+ #[tokio::test]
+ async fn prefilled_input_empty_eof_and_diagnostics() {
+ let runner = Runner::default();
+ for (program, input, expected, code) in [
+ (
+ "DECLARE N : INTEGER\nINPUT N\nOUTPUT N\nOUTPUT \"\\U0001F600\"\n",
+ "42\n",
+ "42\nšŸ˜€\n",
+ 0,
+ ),
+ (
+ "DECLARE S : STRING\nINPUT S\nOUTPUT \"done\"\n",
+ "",
+ "done\n",
+ 0,
+ ),
+ ("OUTPUT Missing\n", "", "compilation terminated.", 1),
+ (
+ "OUTPUT 1 DIV 0\n",
+ "",
+ "Unhandled exception. Program terminated.",
+ 1,
+ ),
+ ] {
+ let source = Source::new(program);
+ let bytes = Mutex::new(Vec::new());
+ let result = timeout(
+ Duration::from_secs(30),
+ runner.run(compiler(), source.0.clone(), input.into(), |_, chunk| {
+ bytes.lock().unwrap().extend(chunk)
+ }),
+ )
+ .await
+ .unwrap()
+ .unwrap();
+ assert_eq!(result.code, Some(code));
+ let text = String::from_utf8(bytes.into_inner().unwrap())
+ .unwrap()
+ .replace("\r\n", "\n");
+ if code == 0 {
+ assert_eq!(text, expected);
+ } else {
+ assert!(text.contains(expected), "{text}");
+ }
+ }
+ }
+
+ #[tokio::test]
+ async fn output_arrives_before_exit_and_stop_allows_restart() {
+ let runner = Runner::default();
+ let source = Source::new("OUTPUT \"ready\"\nWHILE TRUE\nENDWHILE\n");
+ let (send, mut receive) = tokio::sync::mpsc::unbounded_channel();
+ let running = runner.clone();
+ let path = source.0.clone();
+ let task = tokio::spawn(async move {
+ running
+ .run(compiler(), path, String::new(), |_, bytes| {
+ let _ = send.send(bytes);
+ })
+ .await
+ });
+ let mut output = Vec::new();
+ timeout(Duration::from_secs(30), async {
+ while !output.ends_with(b"ready\n") && !output.ends_with(b"ready\r\n") {
+ output.extend(receive.recv().await.expect("output closed before ready"));
+ }
+ })
+ .await
+ .expect("stdout was buffered until exit");
+ assert!(runner
+ .run(compiler(), source.0.clone(), String::new(), |_, _| {})
+ .await
+ .is_err());
+ timeout(Duration::from_secs(5), runner.stop())
+ .await
+ .expect("stop hung");
+ assert!(task.await.unwrap().unwrap().stopped);
+ let source = Source::new("OUTPUT \"again\"\n");
+ assert_eq!(
+ runner
+ .run(compiler(), source.0.clone(), String::new(), |_, _| {})
+ .await
+ .unwrap()
+ .code,
+ Some(0)
+ );
+ }
+}
diff --git a/bundle/src/main.js b/bundle/src/main.js
index 0d3a010..56cbff6 100644
--- a/bundle/src/main.js
+++ b/bundle/src/main.js
@@ -1,4 +1,4 @@
-import { invoke } from '@tauri-apps/api/core';
+import { invoke, Channel } from '@tauri-apps/api/core';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { open, save, message } from '@tauri-apps/plugin-dialog';
import { createEditor } from './editor.js';
@@ -6,6 +6,9 @@ import './style.css';
const status = document.querySelector('#status');
const filename = document.querySelector('#filename');
+const input = document.querySelector('#input');
+const output = document.querySelector('#output');
+const runButton = document.querySelector('#run');
const window = getCurrentWindow();
const filters = [{ name: 'Pseudocode', extensions: ['cps'] }];
let path = null;
@@ -13,6 +16,57 @@ let savedText = '';
let busy = false;
let closing = false;
let editor;
+let running = false;
+
+function setRunning(value) {
+ running = value;
+ runButton.textContent = value ? 'Stop' : 'Run';
+ for (const id of ['new', 'open', 'save']) document.getElementById(id).disabled = value;
+ input.disabled = value;
+ editor.updateOptions({ readOnly: value });
+}
+
+async function runFile() {
+ if (!await saveFile()) return;
+ setRunning(true);
+ output.textContent = '';
+ status.textContent = 'Running';
+ const decoders = { stdout: new TextDecoder(), stderr: new TextDecoder() };
+ let pending = '';
+ let frame = null;
+ function flush() {
+ frame = null;
+ if (!pending) return;
+ output.append(document.createTextNode(pending));
+ pending = '';
+ output.scrollTop = output.scrollHeight;
+ }
+ function append(text) {
+ pending += text;
+ if (frame === null) frame = requestAnimationFrame(flush);
+ }
+ let finish;
+ const finished = new Promise(resolve => { finish = resolve; });
+ const channel = new Channel(event => {
+ if (event.event === 'data') {
+ append(decoders[event.stream].decode(Uint8Array.from(event.bytes), { stream: true }));
+ } else if (event.event === 'finished') {
+ for (const decoder of Object.values(decoders)) append(decoder.decode());
+ if (frame !== null) cancelAnimationFrame(frame);
+ flush();
+ status.textContent = event.stopped ? 'Stopped' : `Exit code: ${event.code ?? 'terminated'}`;
+ finish();
+ }
+ });
+ try {
+ await invoke('run_file', { path, input: input.value, output: channel });
+ await finished;
+ } finally {
+ if (frame !== null) cancelAnimationFrame(frame);
+ flush();
+ setRunning(false);
+ }
+}
function dirty() { return editor.getValue() !== savedText; }
function refreshTitle() {
@@ -43,7 +97,7 @@ async function canReplace() {
return answer === 'Discard' || await saveFile();
}
async function action(callback) {
- if (busy) return;
+ if (busy || running) return;
busy = true;
try { await callback(); } catch (error) { report(error); } finally { busy = false; }
}
@@ -51,6 +105,13 @@ async function action(callback) {
try {
editor = await createEditor(document.querySelector('#editor'));
editor.onDidChangeModelContent(refreshTitle);
+ runButton.onclick = () => {
+ if (running) {
+ invoke('stop_run').catch(report);
+ } else {
+ action(runFile);
+ }
+ };
document.querySelector('#save').onclick = () => action(saveFile);
document.querySelector('#new').onclick = () => action(async () => {
if (!await canReplace()) return;
@@ -80,6 +141,12 @@ try {
await window.onCloseRequested(async event => {
if (closing) return;
event.preventDefault();
+ if (running) {
+ try { await invoke('stop_run'); } catch (error) { report(error); return; }
+ // The command drains pipes before completing; let runFile release its UI lock.
+ busy = false;
+ setRunning(false);
+ }
await action(async () => {
if (!await canReplace()) return;
closing = true;