第 0 阶段:教材项目 2 任务 1 在网页中原样运行

- runtime:仿真 WinForms 子集(Form、Button、Label、TextBox、ComboBox、
  GroupBox、Timer、MessageBox、界面线程消息循环、跨线程检查)、
  教材设备类库(DeviceCtrlHelper、LED 控件)、运行器(常驻编译服务 + 运行模式)
- server:Bun + Hono,WebSocket 会话,每次运行一个进程,消息转发
- web:Vue + Naive UI,WinForms 风格窗体渲染、MessageBox、实训台 LED 屏
- 端到端测试与 100 个程序并发的压力测试
- 教材逐任务摘要(docs/教材摘要)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-22 10:15:14 +08:00
commit 98be8331bc
71 files changed
+7919

No files matched your search

+72
View File
@@ -0,0 +1,72 @@
import type { Subprocess } from 'bun'
import type { Diagnostic } from '@iot/shared'
import { RUNNER_DLL, readLines } from './runner-process'
export interface CompileResult {
ok: boolean
diagnostics: Diagnostic[]
ms: number
}
type Reply = ({ type: 'compiled' } & CompileResult) | { type: 'compileFailed'; message: string }
/**
* 常驻编译服务:全服务器共用一个 C# 编译进程(Roslyn 只加载一次),按请求编号对应回复。
* 进程意外退出时,挂起的请求全部失败,下一次请求自动重启。
*/
class CompileService {
private proc?: Subprocess<'pipe', 'pipe', 'inherit'>
private ready?: Promise<void>
private nextId = 1
private readonly pending = new Map<number, { resolve: (r: CompileResult) => void; reject: (e: Error) => void }>()
/** 编译工程目录;成功时程序集写到同一目录 */
async compile(dir: string): Promise<CompileResult> {
await this.start()
const id = this.nextId++
const result = new Promise<CompileResult>((resolve, reject) => this.pending.set(id, { resolve, reject }))
this.proc!.stdin.write(`${JSON.stringify({ id, dir })}\n`)
this.proc!.stdin.flush()
return result
}
stop() {
this.proc?.kill()
}
/** 服务器启动时调用,提前完成编译器的加载和预热 */
start() {
if (this.ready) return this.ready
const proc = Bun.spawn(['dotnet', RUNNER_DLL, 'compile-server'], {
stdin: 'pipe',
stdout: 'pipe',
stderr: 'inherit',
env: { ...process.env, DOTNET_CLI_TELEMETRY_OPTOUT: '1', DOTNET_NOLOGO: '1' },
})
this.proc = proc
this.ready = new Promise<void>((resolveReady) => {
void readLines(proc.stdout, (line) => {
const message = JSON.parse(line) as ({ type: 'ready' } | Reply) & { id?: number }
if (message.type === 'ready') {
resolveReady()
return
}
const waiter = this.pending.get(message.id!)
if (!waiter) return
this.pending.delete(message.id!)
if (message.type === 'compiled') waiter.resolve(message)
else waiter.reject(new Error(message.message))
})
})
proc.exited.then((code) => {
if (this.proc !== proc) return
this.proc = undefined
this.ready = undefined
for (const waiter of this.pending.values()) waiter.reject(new Error(`编译服务意外退出(${code})`))
this.pending.clear()
})
return this.ready
}
}
export const compileService = new CompileService()
+65
View File
@@ -0,0 +1,65 @@
import { Hono } from 'hono'
import type { ClientMessage, ServerMessage } from '@iot/shared'
import { compileService } from './compile-service'
import { cleanupStaleRuns, RunSession } from './run-session'
import { listSamples, loadSample } from './samples'
await cleanupStaleRuns()
const app = new Hono()
app.get('/api/samples', async (c) => c.json(await listSamples()))
app.get('/api/samples/:id', async (c) => {
const files = await loadSample(c.req.param('id'))
return files ? c.json(files) : c.notFound()
})
interface SocketData {
session?: RunSession
}
const server = Bun.serve({
port: Number(process.env.PORT ?? 3000),
fetch(req, server) {
if (new URL(req.url).pathname === '/ws') {
if (server.upgrade(req, { data: {} })) return
return new Response('WebSocket 升级失败', { status: 400 })
}
return app.fetch(req)
},
websocket: {
data: {} as SocketData,
open(ws) {
ws.data.session = new RunSession((message) => ws.send(JSON.stringify(message)))
},
async message(ws, raw) {
let message: ClientMessage
try {
message = JSON.parse(String(raw))
} catch {
return
}
const session = ws.data.session
if (!session) return
if (message.type === 'run') {
try {
await session.start(message.files)
} catch (error) {
ws.send(JSON.stringify({ type: 'serverError', message: String(error) } satisfies ServerMessage))
}
} else if (message.type === 'stop') {
await session.stop()
} else {
session.input(message)
}
},
async close(ws) {
await ws.data.session?.dispose()
},
},
})
console.log(`服务器已启动:http://localhost:${server.port}`)
await compileService.start()
console.log('C# 编译服务已就绪')
+129
View File
@@ -0,0 +1,129 @@
import { mkdir, mkdtemp, rm } from 'node:fs/promises'
import { dirname, join, normalize, resolve } from 'node:path'
import type { Subprocess } from 'bun'
import type { RunnerInput, RunnerMessage, ServerMessage, SourceFile } from '@iot/shared'
import { compileService } from './compile-service'
import { ROOT, RUNNER_DLL, readLines } from './runner-process'
const RUNS_DIR = join(ROOT, '.runs')
/** 服务器启动时清掉上次异常退出遗留的运行目录 */
export async function cleanupStaleRuns() {
await rm(RUNS_DIR, { recursive: true, force: true })
}
/** 一次运行:写出学生工程 → 编译服务编译 → 启动运行进程,在运行进程和浏览器之间转发消息 */
export class RunSession {
private proc?: Subprocess<'pipe', 'pipe', 'pipe'>
private dir?: string
/** 每次启动、停止都递增,用来丢弃已经过时的编译结果 */
private generation = 0
/** 同一会话的多次"运行"排队依次执行,避免两次启动交错 */
private queue = Promise.resolve()
constructor(private readonly send: (message: ServerMessage) => void) {}
start(files: SourceFile[]) {
const next = this.queue.then(() => this.startNow(files))
this.queue = next.catch(() => {})
return next
}
private async startNow(files: SourceFile[]) {
await this.dispose()
const generation = ++this.generation
await mkdir(RUNS_DIR, { recursive: true })
const dir = await mkdtemp(join(RUNS_DIR, 'run-'))
for (const file of files) {
const target = safeJoin(dir, file.path)
await mkdir(dirname(target), { recursive: true })
await Bun.write(target, file.content)
}
this.send({ type: 'status', state: 'compiling' })
const compiled = await compileService.compile(dir)
// 编译期间又点了一次"运行"或"停止":这次结果作废
if (generation !== this.generation) {
await rm(dir, { recursive: true, force: true })
return
}
this.dir = dir
this.send({ type: 'compiled', ...compiled })
if (!compiled.ok) {
this.send({ type: 'status', state: 'stopped' })
await this.cleanup()
return
}
const proc = Bun.spawn(['dotnet', RUNNER_DLL, 'run', dir], {
stdin: 'pipe',
stdout: 'pipe',
stderr: 'pipe',
env: { ...process.env, DOTNET_CLI_TELEMETRY_OPTOUT: '1', DOTNET_NOLOGO: '1' },
})
this.proc = proc
void readLines(proc.stdout, (line) => {
let message: RunnerMessage
try {
message = JSON.parse(line)
} catch {
this.send({ type: 'console', text: `${line}\n` })
return
}
if (message.type === 'started') this.send({ type: 'status', state: 'running' })
this.send(message)
})
void readLines(proc.stderr, (line) => this.send({ type: 'serverError', message: line }))
proc.exited.then(async (code) => {
if (this.proc !== proc) return
this.proc = undefined
this.send({ type: 'exited', code })
this.send({ type: 'status', state: 'stopped' })
await this.cleanup()
})
}
input(message: RunnerInput) {
if (!this.proc) return
this.proc.stdin.write(`${JSON.stringify(message)}\n`)
this.proc.stdin.flush()
}
async dispose() {
this.generation++
await this.killProc()
await this.cleanup()
}
/** 浏览器点"停止":直接结束进程(不依赖学生程序还能响应) */
async stop() {
this.generation++
await this.killProc()
this.send({ type: 'status', state: 'stopped' })
await this.cleanup()
}
private async killProc() {
const proc = this.proc
this.proc = undefined
if (!proc) return
proc.kill()
await proc.exited
}
private async cleanup() {
if (!this.dir) return
const dir = this.dir
this.dir = undefined
await rm(dir, { recursive: true, force: true })
}
}
/** 拼接路径并拒绝跳出工程目录的文件名 */
function safeJoin(base: string, path: string) {
const target = resolve(base, normalize(path))
if (!target.startsWith(`${base}/`)) throw new Error(`非法文件路径:${path}`)
return target
}
+21
View File
@@ -0,0 +1,21 @@
import { resolve } from 'node:path'
export const ROOT = resolve(import.meta.dir, '../..')
export const RUNNER_DLL = resolve(ROOT, 'runtime/Sim.Runner/bin/Debug/net10.0/Sim.Runner.dll')
/** 逐行读取子进程输出 */
export async function readLines(stream: ReadableStream<Uint8Array>, onLine: (line: string) => void) {
const decoder = new TextDecoder()
let buffer = ''
for await (const chunk of stream) {
buffer += decoder.decode(chunk, { stream: true })
let index = buffer.indexOf('\n')
while (index >= 0) {
const line = buffer.slice(0, index).trimEnd()
buffer = buffer.slice(index + 1)
if (line) onLine(line)
index = buffer.indexOf('\n')
}
}
if (buffer.trim()) onLine(buffer.trim())
}
+33
View File
@@ -0,0 +1,33 @@
import { readdir } from 'node:fs/promises'
import { join, relative, resolve } from 'node:path'
import type { SourceFile } from '@iot/shared'
const SAMPLES_DIR = resolve(import.meta.dir, '../../samples')
export interface SampleInfo {
id: string
title: string
}
/** 示例工程:samples/ 下每个子目录是一个工程,meta.json 里写标题 */
export async function listSamples(): Promise<SampleInfo[]> {
const entries = await readdir(SAMPLES_DIR, { withFileTypes: true })
const samples: SampleInfo[] = []
for (const entry of entries) {
if (!entry.isDirectory()) continue
const meta = await Bun.file(join(SAMPLES_DIR, entry.name, 'meta.json')).json()
samples.push({ id: entry.name, title: meta.title })
}
return samples.sort((a, b) => a.id.localeCompare(b.id))
}
export async function loadSample(id: string): Promise<SourceFile[] | null> {
const dir = resolve(SAMPLES_DIR, id)
if (!dir.startsWith(`${SAMPLES_DIR}/`)) return null
const files: SourceFile[] = []
const glob = new Bun.Glob('**/*.cs')
for await (const path of glob.scan({ cwd: dir })) {
files.push({ path: relative(dir, join(dir, path)), content: await Bun.file(join(dir, path)).text() })
}
return files.length > 0 ? files.sort((a, b) => a.path.localeCompare(b.path)) : null
}