第 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:
commit
98be8331bc
71 files changed
+7919
No files matched your search
@@ -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()
|
||||
@@ -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# 编译服务已就绪')
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user