aboutsummaryrefslogtreecommitdiff
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
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>
-rw-r--r--AGENTS/ARCH.md2
-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
-rw-r--r--tools/driver/EmbeddedRunner.cc6
9 files changed, 431 insertions, 6 deletions
diff --git a/AGENTS/ARCH.md b/AGENTS/ARCH.md
index e6361a9..84d94ed 100644
--- a/AGENTS/ARCH.md
+++ b/AGENTS/ARCH.md
@@ -163,6 +163,8 @@ Driver behavior (`tools/driver/main.cc`):
- Both execution modes return the program's exit status and inherit standard
streams. `-r -o` saves a binary; adding `-S` saves IR instead. Saved output is
kept on execution failure. Execution still uses the configured runner.
+ EMBEDDED disables stdout buffering before JIT initialization so pipe clients
+ receive output during execution, including on Windows's shared host CRT.
All modes require static LLVM, Clang Driver and LLD archives. `cmake/EmbeddedLLVM.cmake` also selects
static compression/terminal support archives when LLVM uses them. Linux Clang
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;
diff --git a/tools/driver/EmbeddedRunner.cc b/tools/driver/EmbeddedRunner.cc
index 587d664..2833cfd 100644
--- a/tools/driver/EmbeddedRunner.cc
+++ b/tools/driver/EmbeddedRunner.cc
@@ -191,6 +191,12 @@ static llvm::Error loadCxxRuntime(llvm::orc::LLJIT &J) {
}
int runEmbedded(const cps::CodeGen &CG, const std::vector<std::string> &Objects) {
+ // Pipes are fully buffered by default. Use the host CRT's stdout, also
+ // preferred by the JIT on Windows, so OUTPUT reaches desktop clients now.
+ if (std::setvbuf(stdout, nullptr, _IONBF, 0) != 0) {
+ llvm::errs() << "error: cannot configure embedded stdout\n";
+ return 1;
+ }
if (llvm::InitializeNativeTarget() || llvm::InitializeNativeTargetAsmPrinter()) {
llvm::errs() << "error: cannot initialize the embedded LLVM target\n";
return 1;