feat(seams): instant-engine registry for QEMU-Linux boards

Generic seam only — no board, OS or runtime specifics in the OSS tree:
an overlay may register an engine that decides, per board, whether a run
needs the Linux guest or can happen in the browser. The decision is data
(engine + reason + where) so the UI can explain a 90 s boot instead of
just taking it, and BoardInstance carries engineMode / enginePinned so
the user's choice is predictable and the terminal panel knows whether an
interactive shell exists. Nothing registers in OSS: the QEMU path is
untouched.
This commit is contained in:
David Montero Crespo 2026-07-29 04:20:07 +02:00
parent 8163869d31
commit 43ef6c9f1d
4 changed files with 128 additions and 8 deletions

View File

@ -256,7 +256,9 @@ export const SerialMonitor: React.FC = () => {
{/* Output area. QEMU-Linux boards get the interactive xterm (shell {/* Output area. QEMU-Linux boards get the interactive xterm (shell
input, line editing, ANSI) instead of the read-only mirror this input, line editing, ANSI) instead of the read-only mirror this
replaced the separate RaspberryPiWorkspace as the one terminal. */} replaced the separate RaspberryPiWorkspace as the one terminal. */}
{isPiBoardKind(activeBoard?.boardKind ?? '') && activeBoard?.running ? ( {isPiBoardKind(activeBoard?.boardKind ?? '') &&
activeBoard?.running &&
activeBoard?.engineMode !== 'instant' ? (
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden' }}> <div style={{ flex: 1, minHeight: 0, overflow: 'hidden' }}>
<PiTerminal key={activeBoard.id} boardId={activeBoard.id} /> <PiTerminal key={activeBoard.id} boardId={activeBoard.id} />
</div> </div>
@ -343,7 +345,11 @@ export const SerialMonitor: React.FC = () => {
)} )}
{/* Input row — the xterm handles Pi input itself */} {/* Input row — the xterm handles Pi input itself */}
{!(isPiBoardKind(activeBoard?.boardKind ?? '') && activeBoard?.running) && ( {!(
isPiBoardKind(activeBoard?.boardKind ?? '') &&
activeBoard?.running &&
activeBoard?.engineMode !== 'instant'
) && (
<div style={styles.inputRow}> <div style={styles.inputRow}>
<input <input
type="text" type="text"

View File

@ -0,0 +1,83 @@
/**
* instantEngine runtime seam for an in-browser engine that can replace a
* QEMU-Linux boot for the boards that don't need a real OS.
*
* QEMU-Linux boards (Raspberry Pi family + overlay piFamily kinds) cost a
* backend process and ~90 s of boot per run. Most projects are just a
* Python script driving GPIO and a screen those can run in the browser
* in a couple of seconds. An overlay registers an engine here; the store
* asks it, per board, whether it can take this run:
*
* Run pressed
* -> engine.decide(boardId) -> 'instant' : engine.run(boardId)
* 'linux' : boot the QEMU guest
*
* The decision is data (`EngineDecision`), not a boolean, so the UI can
* explain WHY a board needs Linux ("script.py:12 uses subprocess") instead
* of silently taking 90 s. Nothing is registered in the OSS build, so the
* QEMU path stays exactly as it was.
*/
export interface EngineDecision {
/** Which engine should take this run. */
engine: 'instant' | 'linux';
/** Short, user-facing reason. Empty when 'instant' with nothing to say. */
reason: string;
/** Optional `file:line` the reason refers to, for the tooltip. */
where?: string;
}
export interface InstantEngine {
/** Decide which engine runs this board's current files. */
decide(boardId: string): EngineDecision;
/** Run the board's files in the browser. Resolves when the script ends. */
run(boardId: string): Promise<void>;
/** Cancel a running script (the runtime may stay warm). */
stop(boardId: string): void;
}
let engine: InstantEngine | null = null;
const listeners = new Set<() => void>();
export function registerInstantEngine(e: InstantEngine | null): void {
engine = e;
for (const l of listeners) l();
}
export function getInstantEngine(): InstantEngine | null {
return engine;
}
/** Subscribe to registration (the overlay loads asynchronously). */
export function subscribeInstantEngine(cb: () => void): () => void {
listeners.add(cb);
return () => listeners.delete(cb);
}
/**
* Which engine should run this board, honouring a user override.
*
* `pinned` comes from the board instance (the user clicked the mode chip
* or turned on the Linux terminal); when set it wins over the detector,
* because predictability beats cleverness for a project you already know.
*/
export function decideEngine(
boardId: string,
pinned?: 'instant' | 'linux',
): EngineDecision {
if (pinned === 'linux') {
return { engine: 'linux', reason: 'Linux mode is on for this project' };
}
if (!engine) {
return { engine: 'linux', reason: 'the instant engine is not available' };
}
if (pinned === 'instant') {
return { engine: 'instant', reason: 'instant mode is pinned for this project' };
}
try {
return engine.decide(boardId);
} catch {
// A detector bug must never block a run: fall back to the real machine.
return { engine: 'linux', reason: 'could not analyse the project' };
}
}

View File

@ -1,4 +1,5 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { decideEngine, getInstantEngine } from '../lib/instantEngine';
import { getProBoard, isProBoardSimulator, type ProBoardSimulator } from '../lib/proBoardRegistry'; import { getProBoard, isProBoardSimulator, type ProBoardSimulator } from '../lib/proBoardRegistry';
import { AVRSimulator } from '../simulation/AVRSimulator'; import { AVRSimulator } from '../simulation/AVRSimulator';
import { RP2040Simulator } from '../simulation/RP2040Simulator'; import { RP2040Simulator } from '../simulation/RP2040Simulator';
@ -1898,11 +1899,31 @@ export const useSimulatorStore = create<SimulatorState>((set, get) => {
} }
if (isPiBoardKind(board.boardKind)) { if (isPiBoardKind(board.boardKind)) {
getBoardBridge(boardId)?.connect(); // Engine routing: most projects are a Python script driving GPIO and
// Pop the terminal open so the user watches the guest boot and gets // a screen, and those run in the browser in seconds instead of
// the interactive shell — without it a Linux board looks frozen for // booting a Linux guest (a backend process + ~90 s). The detector
// the ~45 s boot. // lives in the overlay; `enginePinned` lets the user override it.
set({ serialMonitorOpen: true }); const decision = decideEngine(boardId, board.enginePinned);
set((s) => ({
boards: s.boards.map((b) =>
b.id === boardId ? { ...b, engineMode: decision.engine } : b,
),
serialMonitorOpen: true,
}));
if (decision.engine === 'instant') {
const instant = getInstantEngine();
void instant?.run(boardId).finally(() => {
set((s) => {
const boards = s.boards.map((b) =>
b.id === boardId ? { ...b, running: false } : b,
);
const isActive = s.activeBoardId === boardId;
return { boards, ...(isActive ? { running: false } : {}) };
});
});
} else {
getBoardBridge(boardId)?.connect();
}
} else if (isEsp32Kind(board.boardKind)) { } else if (isEsp32Kind(board.boardKind)) {
// Pre-register sensors connected to this board so the QEMU worker // Pre-register sensors connected to this board so the QEMU worker
// has them ready before the firmware starts executing. // has them ready before the firmware starts executing.
@ -2142,7 +2163,8 @@ export const useSimulatorStore = create<SimulatorState>((set, get) => {
if (!board) return; if (!board) return;
if (isPiBoardKind(board.boardKind)) { if (isPiBoardKind(board.boardKind)) {
getBoardBridge(boardId)?.disconnect(); if (board.engineMode === 'instant') getInstantEngine()?.stop(boardId);
else getBoardBridge(boardId)?.disconnect();
} else if (isEsp32Kind(board.boardKind)) { } else if (isEsp32Kind(board.boardKind)) {
getEsp32Bridge(boardId)?.disconnect(); getEsp32Bridge(boardId)?.disconnect();
} else if (isStm32BoardKind(board.boardKind)) { } else if (isStm32BoardKind(board.boardKind)) {

View File

@ -122,6 +122,15 @@ export interface BoardInstance {
// the bridge sees the boot-complete marker, and drives the "Booting…" overlay // the bridge sees the boot-complete marker, and drives the "Booting…" overlay
// and gates file uploads. Undefined/false for non-Pi boards and pre-boot. // and gates file uploads. Undefined/false for non-Pi boards and pre-boot.
piBooted?: boolean; piBooted?: boolean;
/** QEMU-Linux boards only. Which engine this board is running on (or ran
* last): 'instant' = in-browser Python, 'linux' = the QEMU guest. Drives
* the mode chip and the terminal panel (interactive shell only exists in
* Linux mode). Undefined until the first run. */
engineMode?: 'instant' | 'linux';
/** User override for the engine, persisted with the project. Set by the
* mode chip or by turning on the Linux terminal; wins over the detector
* so a project doesn't silently change behaviour between runs. */
enginePinned?: 'instant' | 'linux';
compiledProgram: string | null; // hex for AVR/RP2040, null for Pi (runs Python) compiledProgram: string | null; // hex for AVR/RP2040, null for Pi (runs Python)
serialOutput: string; serialOutput: string;
serialBaudRate: number; serialBaudRate: number;