第 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

+17
View File
@@ -0,0 +1,17 @@
{
"name": "@iot/server",
"private": true,
"type": "module",
"scripts": {
"dev": "bun --watch src/index.ts",
"start": "bun src/index.ts"
},
"dependencies": {
"@iot/shared": "workspace:*",
"hono": "^4.7.0"
},
"devDependencies": {
"@types/bun": "^1.2.0",
"typescript": "^5.8.0"
}
}
+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
}
+102
View File
@@ -0,0 +1,102 @@
// 并发压力测试:同时运行 N 个学生程序(教材项目 2 任务 1),测量内存、启动时间和操作往返延迟。
// 用法:bun test/load.ts [N],默认 100。运行前先编译运行时:bun run build:runtime
import { readFileSync } from 'node:fs'
import { cp, mkdir, rm } from 'node:fs/promises'
import { resolve } from 'node:path'
import type { ControlNode, RunnerMessage } from '@iot/shared'
import { compileService } from '../src/compile-service'
import { ROOT, RUNNER_DLL, readLines } from '../src/runner-process'
const COUNT = Number(process.argv[2] ?? 100)
const dir = resolve(ROOT, '.runs/load-test')
await rm(dir, { recursive: true, force: true })
await mkdir(dir, { recursive: true })
await cp(resolve(ROOT, 'samples/p2-t1-LED显示屏'), dir, { recursive: true })
const compiled = await compileService.compile(dir)
compileService.stop()
if (!compiled.ok) throw new Error('示例编译失败')
interface Instance {
proc: Bun.Subprocess<'pipe', 'pipe', 'inherit'>
form?: ControlNode
onMessage?: (m: RunnerMessage) => void
}
function findId(node: ControlNode, name: string): string | undefined {
if (node.name === name) return node.id
for (const child of node.children) {
const id = findId(child, name)
if (id) return id
}
}
function send(instance: Instance, message: object) {
instance.proc.stdin.write(`${JSON.stringify(message)}\n`)
instance.proc.stdin.flush()
}
/** 读取 /proc 里的内存数据(MB)。Rss 会把多个进程共享的 .NET 运行库重复计算,Pss 按进程数平摊,合计值更接近真实占用 */
function memoryMb(pid: number, field: 'Rss' | 'Pss') {
const rollup = readFileSync(`/proc/${pid}/smaps_rollup`, 'utf8')
return Number(new RegExp(`^${field}:\\s+(\\d+)`, 'm').exec(rollup)![1]) / 1024
}
console.log(`启动 ${COUNT} 个学生程序…`)
const startAt = performance.now()
const instances: Instance[] = []
const shown: Promise<void>[] = []
for (let i = 0; i < COUNT; i++) {
const proc = Bun.spawn(['dotnet', RUNNER_DLL, 'run', dir], { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' })
const instance: Instance = { proc }
instances.push(instance)
shown.push(
new Promise((resolveShown) => {
void readLines(proc.stdout, (line) => {
const message = JSON.parse(line) as RunnerMessage
if (message.type === 'formShown') {
instance.form = message.form
resolveShown()
}
instance.onMessage?.(message)
})
}),
)
}
await Promise.all(shown)
console.log(`全部窗体显示完成:${Math.round(performance.now() - startAt)} ms`)
await Bun.sleep(3000)
for (const field of ['Rss', 'Pss'] as const) {
const values = instances.map((i) => memoryMb(i.proc.pid, field))
const total = values.reduce((a, b) => a + b, 0)
console.log(`内存 ${field}:合计 ${Math.round(total)} MB,平均 ${Math.round(total / COUNT)} MB`)
}
// 每个程序:选 COM5 → 打开 → 输入文字 → 推送,测量从发出"推送"到收到 LED 变化的往返时间
const latencies = await Promise.all(
instances.map(async (instance) => {
const form = instance.form!
const id = (name: string) => findId(form, name)!
send(instance, { type: 'action', id: id('com_Port'), action: 'dropDown' })
send(instance, { type: 'action', id: id('com_Port'), action: 'select', index: 4 })
send(instance, { type: 'action', id: id('btn_Open'), action: 'click' })
send(instance, { type: 'action', id: id('txt_SendText'), action: 'text', value: '压力测试' })
await Bun.sleep(200)
const led = id('led')
const sentAt = performance.now()
const done = new Promise<number>((resolveDone) => {
instance.onMessage = (m) => {
if (m.type === 'patch' && m.changes.some((c) => c.id === led && c.prop === 'LedText')) resolveDone(performance.now() - sentAt)
}
})
send(instance, { type: 'action', id: id('btn_Send'), action: 'click' })
return done
}),
)
latencies.sort((a, b) => a - b)
const pct = (p: number) => Math.round(latencies[Math.min(latencies.length - 1, Math.floor((latencies.length * p) / 100))])
console.log(`操作往返(${COUNT} 个程序同时点击):中位数 ${pct(50)} ms,P95 ${pct(95)} ms,最大 ${pct(100)} ms`)
for (const instance of instances) instance.proc.kill()
await rm(dir, { recursive: true, force: true })
+149
View File
@@ -0,0 +1,149 @@
// 端到端测试:直接启动 C# 运行器,模拟浏览器操作,检查教材项目 2 任务 1 的行为。
// 运行前先编译运行时:bun run build:runtime
import { afterAll, afterEach, expect, test } from 'bun:test'
import { resolve } from 'node:path'
import type { ControlNode, RunnerInput, RunnerMessage } from '@iot/shared'
import { compileService } from '../src/compile-service'
import { ROOT, RUNNER_DLL } from '../src/runner-process'
class Runner {
readonly messages: RunnerMessage[] = []
private proc?: Bun.Subprocess<'pipe', 'pipe', 'inherit'>
private waiters: { match: (m: RunnerMessage) => boolean; resolve: (m: RunnerMessage) => void }[] = []
/** 与服务器相同的流程:先由编译服务编译,成功后启动运行进程 */
static async start(projectDir: string) {
const runner = new Runner()
const compiled = await compileService.compile(projectDir)
runner.receive({ type: 'compiled', ...compiled })
if (compiled.ok) {
runner.proc = Bun.spawn(['dotnet', RUNNER_DLL, 'run', projectDir], { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' })
void runner.read()
}
return runner
}
private receive(message: RunnerMessage) {
this.messages.push(message)
this.waiters = this.waiters.filter((w) => {
if (!w.match(message)) return true
w.resolve(message)
return false
})
}
private async read() {
const decoder = new TextDecoder()
let buffer = ''
for await (const chunk of this.proc!.stdout) {
buffer += decoder.decode(chunk, { stream: true })
let index: number
while ((index = buffer.indexOf('\n')) >= 0) {
const line = buffer.slice(0, index)
buffer = buffer.slice(index + 1)
if (!line.trim()) continue
this.receive(JSON.parse(line) as RunnerMessage)
}
}
}
/** 等待下一条满足条件的消息(已经收到的也算) */
waitFor<T extends RunnerMessage['type']>(
type: T,
match: (m: Extract<RunnerMessage, { type: T }>) => boolean = () => true,
timeoutMs = 15000,
): Promise<Extract<RunnerMessage, { type: T }>> {
const isMatch = (m: RunnerMessage) => m.type === type && match(m as Extract<RunnerMessage, { type: T }>)
const existing = this.messages.find(isMatch)
if (existing) return Promise.resolve(existing as Extract<RunnerMessage, { type: T }>)
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(`等待 ${type} 超时`)), timeoutMs)
this.waiters.push({
match: isMatch,
resolve: (m) => {
clearTimeout(timer)
resolve(m as Extract<RunnerMessage, { type: T }>)
},
})
})
}
send(message: RunnerInput) {
this.proc!.stdin.write(`${JSON.stringify(message)}\n`)
this.proc!.stdin.flush()
}
kill() {
this.proc?.kill()
}
}
function findByName(node: ControlNode, name: string): ControlNode {
if (node.name === name) return node
for (const child of node.children) {
const found = findByName(child, name)
if (found.name === name) return found
}
return { id: '', type: '', name: '', props: {}, children: [] }
}
let runner: Runner | undefined
afterEach(() => runner?.kill())
afterAll(() => compileService.stop())
test('项目2 任务1:选串口、打开、推送文字到 LED 屏', async () => {
// 编译产物写在工程目录里,所以复制到临时目录再编译
const dir = resolve(ROOT, '.runs/test-led')
const sample = resolve(ROOT, 'samples/p2-t1-LED显示屏')
for (const name of ['Program.cs', 'Form1.cs', 'Form1.Designer.cs']) await Bun.write(`${dir}/${name}`, Bun.file(`${sample}/${name}`))
runner = await Runner.start(dir)
const compiled = await runner.waitFor('compiled')
expect(compiled.diagnostics).toEqual([])
const { form } = await runner.waitFor('formShown')
expect(form.props.Text).toBe('LED显示屏')
const id = (name: string) => findByName(form, name).id
const comPort = id('com_Port')
// 先推送、不打开串口:教材约定返回 "Error:发送失败",学生代码弹出 MessageBox
runner.send({ type: 'action', id: id('txt_SendText'), action: 'text', value: '欢迎学习物联网' })
runner.send({ type: 'action', id: id('btn_Send'), action: 'click' })
const box = await runner.waitFor('messageBox')
expect(box.text).toBe('Error:发送失败')
runner.send({ type: 'reply', requestId: box.requestId, result: 'OK' })
// 点下拉框:Click 事件里填充串口列表
runner.send({ type: 'action', id: comPort, action: 'dropDown' })
const items = await runner.waitFor('patch', (m) => m.changes.some((c) => c.id === comPort && c.prop === 'Items'))
const ports = items.changes.findLast((c) => c.id === comPort && c.prop === 'Items')!.value as string[]
expect(ports).toContain('COM5')
runner.send({ type: 'action', id: comPort, action: 'select', index: ports.indexOf('COM5') })
runner.send({ type: 'action', id: id('btn_Open'), action: 'click' })
runner.send({ type: 'action', id: id('btn_Send'), action: 'click' })
const device = await runner.waitFor('device', (m) => m.device === 'led')
expect(device.data).toBe('欢迎学习物联网')
const led = id('led')
const ledPatch = await runner.waitFor('patch', (m) => m.changes.some((c) => c.id === led && c.prop === 'LedText'))
expect(ledPatch.changes.find((c) => c.id === led)!.value).toBe('欢迎学习物联网')
// 关闭主窗体:程序结束
runner.send({ type: 'action', id: form.id, action: 'close' })
await runner.waitFor('formClosed')
}, 30000)
test('编译错误:书上常见的 True 大写给出中文提示', async () => {
const dir = resolve(ROOT, '.runs/test-true')
const sample = resolve(ROOT, 'samples/p2-t1-LED显示屏')
for (const name of ['Program.cs', 'Form1.Designer.cs']) await Bun.write(`${dir}/${name}`, Bun.file(`${sample}/${name}`))
const form1 = await Bun.file(`${sample}/Form1.cs`).text()
await Bun.write(`${dir}/Form1.cs`, form1.replace('private void btn_Open_Click(object sender, EventArgs e)\n {', 'private void btn_Open_Click(object sender, EventArgs e)\n {\n bool IsOpen = True;'))
runner = await Runner.start(dir)
const compiled = await runner.waitFor('compiled')
expect(compiled.ok).toBe(false)
const error = compiled.diagnostics.find((d) => d.code === 'CS0103')
expect(error?.message).toContain('True')
expect(error?.hint).toContain('小写')
}, 30000)
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "Preserve",
"moduleResolution": "bundler",
"lib": ["ESNext"],
"types": ["bun"],
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"verbatimModuleSyntax": true
},
"include": ["src", "test", "../shared"]
}