Build Phase 2 judge vertical slice

This commit is contained in:
2026-08-06 22:42:39 -06:00
parent e6329ecabb
commit ec274419c3
41 changed files with 2945 additions and 669 deletions

243
apps/api/src/judge/ast.ts Normal file
View File

@@ -0,0 +1,243 @@
import { Language, Parser, type Node } from "web-tree-sitter"
export interface AstRule {
engine?: string
target?: string
outer?: string
inner?: string
label?: string
message?: string
exact?: number
min?: number
max?: number
}
export interface AstResult {
description: string
passed: boolean
}
const mappings: Record<string, Record<string, string>> = {
C: {
for_loop: "for_statement",
while_loop: "while_statement",
do_while: "do_statement",
if_statement: "if_statement",
else_clause: "else_clause",
break: "break_statement",
continue: "continue_statement",
function_definition: "function_definition",
return: "return_statement",
switch_statement: "switch_statement",
case_statement: "case_statement",
assignment: "assignment_expression",
struct: "struct_specifier",
include: "preproc_include",
and: "&&",
or: "||",
not: "!",
},
Python3: {
for_loop: "for_statement",
while_loop: "while_statement",
if_statement: "if_statement",
else_clause: "else_clause",
elif_clause: "elif_clause",
break: "break_statement",
continue: "continue_statement",
function_definition: "function_definition",
return: "return_statement",
try_except: "try_statement",
with_statement: "with_statement",
list_comprehension: "list_comprehension",
list_literal: "list",
dict_literal: "dictionary",
set_literal: "set",
f_string: "format_string",
import: "import_statement",
import_from: "import_from_statement",
assignment: "assignment",
class_definition: "class_definition",
},
}
let initPromise: Promise<void> | undefined
const languages = new Map<string, Language>()
async function loadLanguage(language: string) {
if (!(language in mappings)) return null
if (!initPromise) initPromise = Parser.init()
await initPromise
const cached = languages.get(language)
if (cached) return cached
const packageName = language === "C" ? "tree-sitter-c" : "tree-sitter-python"
const wasmName = language === "C" ? "tree-sitter-c.wasm" : "tree-sitter-python.wasm"
const wasmPath = Bun.resolveSync(`${packageName}/${wasmName}`, import.meta.dir)
const loaded = await Language.load(wasmPath)
languages.set(language, loaded)
return loaded
}
function collectNodes(root: Node, type: string, result: Node[] = []) {
if (root.type === type) result.push(root)
for (const child of root.children) collectNodes(child, type, result)
return result
}
function hasNode(root: Node, type: string): boolean {
if (root.type === type) return true
return root.children.some((child) => hasNode(child, type))
}
function targetName(rule: AstRule) {
return rule.label || rule.target || "指定语法"
}
function rangeDescription(subject: string, rule: AstRule) {
if (rule.message) return rule.message
if (rule.exact !== undefined) return `${subject} 出现 ${rule.exact}`
const parts: string[] = []
if (rule.min !== undefined) parts.push(`至少 ${rule.min}`)
if (rule.max !== undefined) parts.push(`至多 ${rule.max}`)
return `${subject} ${parts.join("、")}`.trim()
}
function rangePassed(count: number, rule: AstRule) {
if (rule.exact !== undefined && count !== rule.exact) return false
if (rule.min !== undefined && count < rule.min) return false
if (rule.max !== undefined && count > rule.max) return false
return true
}
function functionCalls(root: Node, target: string, language: string) {
const callType = language === "C" ? "call_expression" : "call"
return collectNodes(root, callType).filter((call) => {
const fn = call.childForFieldName("function")
return fn?.type === "identifier" && fn.text === target
})
}
function methodCalls(root: Node, target: string, language: string) {
if (language === "C") return []
return collectNodes(root, "call").filter((call) => {
const fn = call.childForFieldName("function")
return (
fn?.type === "attribute" &&
fn.childForFieldName("attribute")?.text === target
)
})
}
function evaluateRule(
root: Node,
rule: AstRule,
language: string,
mapping: Record<string, string>,
): AstResult | null {
const target = rule.target ?? ""
const nodeType = mapping[target] ?? target
switch (rule.engine) {
case "must_exist_node":
return {
description: rule.message || `必须使用 ${targetName(rule)}`,
passed: hasNode(root, nodeType),
}
case "must_not_exist_node":
return {
description: rule.message || `不能使用 ${targetName(rule)}`,
passed: !hasNode(root, nodeType),
}
case "count_node": {
const count = collectNodes(root, nodeType).length
return {
description: rangeDescription(targetName(rule), rule),
passed: rangePassed(count, rule),
}
}
case "must_call_function":
return {
description: rule.message || `必须调用 ${target}()`,
passed: functionCalls(root, target, language).length > 0,
}
case "must_not_call_function":
return {
description: rule.message || `不能调用 ${target}()`,
passed: functionCalls(root, target, language).length === 0,
}
case "count_function_call": {
const count = functionCalls(root, target, language).length
return {
description: rangeDescription(`${target}()`, rule),
passed: rangePassed(count, rule),
}
}
case "must_call_method":
return {
description: rule.message || `必须调用 .${target}()`,
passed: methodCalls(root, target, language).length > 0,
}
case "must_not_call_method":
return {
description: rule.message || `不能调用 .${target}()`,
passed: methodCalls(root, target, language).length === 0,
}
case "must_use_operator":
return {
description: rule.message || `必须使用 ${target} 运算符`,
passed: hasNode(root, nodeType),
}
case "must_have_nesting": {
const outer = rule.outer ?? ""
const inner = rule.inner ?? ""
const outerType = mapping[outer] ?? outer
const innerType = mapping[inner] ?? inner
const passed = collectNodes(root, outerType).some((node) =>
node.children.some((child) => hasNode(child, innerType)),
)
return {
description:
rule.message ||
(outer === inner
? `必须使用 ${outer} 嵌套`
: `必须在 ${outer} 中嵌套使用 ${inner}`),
passed,
}
}
default:
return null
}
}
export async function checkAst(
code: string,
language: string,
rules: AstRule[],
): Promise<{ passed: boolean; results: AstResult[] }> {
if (rules.length === 0) return { passed: true, results: [] }
const treeSitterLanguage = await loadLanguage(language)
if (!treeSitterLanguage) return { passed: true, results: [] }
const parser = new Parser()
try {
parser.setLanguage(treeSitterLanguage)
const tree = parser.parse(code)
if (!tree) return { passed: true, results: [] }
try {
const mapping = mappings[language] ?? {}
const results = rules
.map((rule) => evaluateRule(tree.rootNode, rule, language, mapping))
.filter((result): result is AstResult => result !== null)
return { passed: results.every((result) => result.passed), results }
} finally {
tree.delete()
}
} catch {
return { passed: true, results: [] }
} finally {
parser.delete()
}
}

View File

@@ -0,0 +1,41 @@
import {
submissionUpdateSchema,
type SubmissionUpdate,
} from "@oj2/contract"
import { redis } from "../redis"
export const submissionUpdateChannel = "submission:updates"
interface SubmissionEvent {
userId: number
data: SubmissionUpdate
}
export function userSubmissionTopic(userId: number) {
return `submission:user:${userId}`
}
export async function publishSubmissionUpdate(
userId: number,
data: SubmissionUpdate,
) {
const event: SubmissionEvent = {
userId,
data: submissionUpdateSchema.parse(data),
}
await redis.publish(submissionUpdateChannel, JSON.stringify(event))
}
export function parseSubmissionEvent(raw: string): SubmissionEvent | null {
try {
const value = JSON.parse(raw) as { userId?: unknown; data?: unknown }
if (typeof value.userId !== "number") return null
return {
userId: value.userId,
data: submissionUpdateSchema.parse(value.data),
}
} catch {
return null
}
}

View File

@@ -0,0 +1,6 @@
export const judgeQueueName = "judge-submission"
export interface JudgeJobData {
submissionId: string
problemId: number
}

View File

@@ -0,0 +1,107 @@
const defaultEnv = ["LANG=en_US.UTF-8", "LANGUAGE=en_US:en", "LC_ALL=en_US.UTF-8"]
export const languageConfigs: Record<string, Record<string, unknown>> = {
C: {
template: "",
compile: {
src_name: "main.c",
exe_name: "main",
max_cpu_time: 3000,
max_real_time: 10000,
max_memory: 256 * 1024 * 1024,
compile_command:
"/usr/bin/gcc -DONLINE_JUDGE -O2 -w -fmax-errors=3 -std=c17 {src_path} -lm -o {exe_path}",
},
run: {
command: "{exe_path}",
seccomp_rule: { "Standard IO": "c_cpp", "File IO": "c_cpp_file_io" },
env: defaultEnv,
},
},
"C++": {
template: "",
compile: {
src_name: "main.cpp",
exe_name: "main",
max_cpu_time: 10000,
max_real_time: 20000,
max_memory: 1024 * 1024 * 1024,
compile_command:
"/usr/bin/g++ -DONLINE_JUDGE -O2 -w -fmax-errors=3 -std=c++20 {src_path} -lm -o {exe_path}",
},
run: {
command: "{exe_path}",
seccomp_rule: { "Standard IO": "c_cpp", "File IO": "c_cpp_file_io" },
env: defaultEnv,
},
},
Java: {
template: "",
compile: {
src_name: "Main.java",
exe_name: "Main",
max_cpu_time: 5000,
max_real_time: 10000,
max_memory: -1,
compile_command: "/usr/bin/javac {src_path} -d {exe_dir}",
},
run: {
command: "/usr/bin/java -cp {exe_dir} -XX:MaxRAM={max_memory}k Main",
seccomp_rule: null,
env: defaultEnv,
memory_limit_check_only: 1,
},
},
Python3: {
template: "",
compile: {
src_name: "solution.py",
exe_name: "solution.py",
max_cpu_time: 3000,
max_real_time: 10000,
max_memory: 128 * 1024 * 1024,
compile_command: "/usr/bin/python3 -m py_compile {src_path}",
},
run: {
command: "/usr/bin/python3 -BS {exe_path}",
seccomp_rule: "general",
env: defaultEnv,
},
},
Golang: {
template: "",
compile: {
src_name: "main.go",
exe_name: "main",
max_cpu_time: 3000,
max_real_time: 5000,
max_memory: 1024 * 1024 * 1024,
compile_command: "/usr/bin/go build -o {exe_path} {src_path}",
env: ["GOCACHE=/tmp", "GOPATH=/tmp", "GOMAXPROCS=1", ...defaultEnv],
},
run: {
command: "{exe_path}",
seccomp_rule: "golang",
env: ["GOMAXPROCS=1", ...defaultEnv],
memory_limit_check_only: 1,
},
},
JavaScript: {
template: "",
compile: {
src_name: "main.js",
exe_name: "main.js",
max_cpu_time: 3000,
max_real_time: 5000,
max_memory: 1024 * 1024 * 1024,
compile_command: "/usr/bin/node --check {src_path}",
env: defaultEnv,
},
run: {
command: "/usr/bin/node {exe_path}",
seccomp_rule: "node",
env: defaultEnv,
memory_limit_check_only: 1,
},
},
}

355
apps/api/src/judge/run.ts Normal file
View File

@@ -0,0 +1,355 @@
import { createHash } from "node:crypto"
import { and, eq, inArray } from "drizzle-orm"
import { config } from "../config"
import { db, schema } from "../db"
import { checkAst, type AstRule } from "./ast"
import { publishSubmissionUpdate } from "./events"
import type { JudgeJobData } from "./job"
import { languageConfigs } from "./languages"
import {
isAccepted,
JudgeStatus,
type JudgeStatusValue,
} from "./status"
import { parseProblemTemplate } from "./template"
interface JudgeCase {
cpu_time: number
memory: number
result: number
test_case: string
[key: string]: unknown
}
interface JudgeResponse {
err: string | null
data: JudgeCase[] | unknown
}
function objectValue(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {}
}
function statusValue(value: number): JudgeStatusValue {
const statuses = new Set<number>(Object.values(JudgeStatus))
return statuses.has(value)
? (value as JudgeStatusValue)
: JudgeStatus.SYSTEM_ERROR
}
function templateForLanguage(value: unknown, language: string) {
const template = objectValue(value)[language]
return typeof template === "string" ? template : null
}
function astRulesForLanguage(value: unknown, language: string): AstRule[] {
const rules = objectValue(value)[language]
return Array.isArray(rules) ? (rules as AstRule[]) : []
}
async function requestJudge(
language: string,
code: string,
timeLimit: number,
memoryLimit: number,
testCaseId: string,
) {
const languageConfig = languageConfigs[language]
if (!languageConfig) throw new Error(`Unsupported judge language: ${language}`)
const token = createHash("sha256")
.update(config.judgeServerToken)
.digest("hex")
const response = await fetch(new URL("/judge", config.judgeServerUrl), {
method: "POST",
headers: {
"content-type": "application/json",
"X-Judge-Server-Token": token,
},
body: JSON.stringify({
language_config: languageConfig,
src: code,
max_cpu_time: timeLimit,
max_memory: 1024 * 1024 * memoryLimit,
test_case_id: testCaseId,
output: false,
io_mode: {
io_mode: "Standard IO",
input: "input.txt",
output: "output.txt",
},
}),
})
if (!response.ok) {
throw new Error(`JudgeServer returned HTTP ${response.status}`)
}
return (await response.json()) as JudgeResponse
}
async function persistResult(
submissionId: string,
problemId: number,
userId: number,
displayId: string,
result: JudgeStatusValue,
info: unknown,
statisticInfo: Record<string, unknown>,
) {
return db.transaction(async (tx) => {
const [currentSubmission] = await tx
.select({ result: schema.submission.result })
.from(schema.submission)
.where(eq(schema.submission.id, submissionId))
.for("update")
if (
!currentSubmission ||
![JudgeStatus.PENDING, JudgeStatus.JUDGING].includes(
currentSubmission.result as 6 | 7,
)
) {
return false
}
const [problem] = await tx
.select({
submissionNumber: schema.problem.submissionNumber,
acceptedNumber: schema.problem.acceptedNumber,
statisticInfo: schema.problem.statisticInfo,
})
.from(schema.problem)
.where(eq(schema.problem.id, problemId))
.for("update")
const [profile] = await tx
.select()
.from(schema.userProfile)
.where(eq(schema.userProfile.userId, userId))
.for("update")
if (!problem || !profile) {
throw new Error("Submission dependencies disappeared during judging")
}
await tx
.update(schema.submission)
.set({ result, info, statisticInfo })
.where(eq(schema.submission.id, submissionId))
const problemStatistics = objectValue(problem.statisticInfo)
const resultKey = String(result)
const previousResultCount = problemStatistics[resultKey]
problemStatistics[resultKey] =
(typeof previousResultCount === "number" ? previousResultCount : 0) + 1
await tx
.update(schema.problem)
.set({
submissionNumber: problem.submissionNumber + 1,
acceptedNumber:
problem.acceptedNumber + (isAccepted(result) ? 1 : 0),
statisticInfo: problemStatistics,
})
.where(eq(schema.problem.id, problemId))
const acmStatus = objectValue(profile.acmProblemsStatus)
const problems = objectValue(acmStatus.problems)
const previous = objectValue(problems[String(problemId)])
const previousStatus = previous.status
const wasAccepted =
typeof previousStatus === "number" && isAccepted(previousStatus)
const acceptedNow = isAccepted(result)
if (previousStatus === undefined) {
problems[String(problemId)] = {
status: acceptedNow ? JudgeStatus.ACCEPTED : result,
_id: displayId,
}
} else if (!wasAccepted) {
problems[String(problemId)] = {
...previous,
status: acceptedNow ? JudgeStatus.ACCEPTED : result,
_id: displayId,
}
}
acmStatus.problems = problems
await tx
.update(schema.userProfile)
.set({
submissionNumber: profile.submissionNumber + 1,
acceptedNumber:
profile.acceptedNumber + (acceptedNow && !wasAccepted ? 1 : 0),
acmProblemsStatus: acmStatus,
})
.where(eq(schema.userProfile.id, profile.id))
return true
})
}
async function markSystemError(submissionId: string, userId: number, error: unknown) {
const message = error instanceof Error ? error.message : String(error)
const updated = await db
.update(schema.submission)
.set({
result: JudgeStatus.SYSTEM_ERROR,
statisticInfo: { err_info: message, score: 0 },
})
.where(
and(
eq(schema.submission.id, submissionId),
inArray(schema.submission.result, [
JudgeStatus.PENDING,
JudgeStatus.JUDGING,
]),
),
)
.returning({ id: schema.submission.id })
if (updated.length > 0) {
await publishSubmissionUpdate(userId, {
type: "submission_update",
submission_id: submissionId,
result: JudgeStatus.SYSTEM_ERROR,
status: "error",
err_info: message,
})
}
}
export async function judgeSubmission(job: JudgeJobData) {
const [row] = await db
.select({
submission: schema.submission,
problem: schema.problem,
})
.from(schema.submission)
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id))
.where(
and(
eq(schema.submission.id, job.submissionId),
eq(schema.problem.id, job.problemId),
),
)
.limit(1)
if (!row) throw new Error(`Submission ${job.submissionId} does not exist`)
if (![JudgeStatus.PENDING, JudgeStatus.JUDGING].includes(row.submission.result as 6 | 7)) {
return
}
try {
await db
.update(schema.submission)
.set({ result: JudgeStatus.JUDGING })
.where(eq(schema.submission.id, row.submission.id))
await publishSubmissionUpdate(row.submission.userId, {
type: "submission_update",
submission_id: row.submission.id,
result: JudgeStatus.JUDGING,
status: "judging",
})
const rawTemplate = templateForLanguage(
row.problem.template,
row.submission.language,
)
const template = rawTemplate ? parseProblemTemplate(rawTemplate) : null
const source = template
? `${template.prepend}\n${row.submission.code}\n${template.append}`
: row.submission.code
const response = await requestJudge(
row.submission.language,
source,
row.problem.timeLimit,
row.problem.memoryLimit,
row.problem.testCaseId,
)
let result: JudgeStatusValue
let info: unknown = {}
let statisticInfo: Record<string, unknown> = {}
if (response.err) {
result = JudgeStatus.COMPILE_ERROR
statisticInfo = {
err_info:
typeof response.data === "string"
? response.data
: JSON.stringify(response.data),
score: 0,
}
} else {
if (!Array.isArray(response.data)) {
throw new Error("JudgeServer returned an invalid result payload")
}
const cases = [...response.data].sort(
(left, right) => Number(left.test_case) - Number(right.test_case),
)
info = { err: null, data: cases }
const firstFailure = cases.find((item) => item.result !== JudgeStatus.ACCEPTED)
result = statusValue(firstFailure?.result ?? JudgeStatus.ACCEPTED)
statisticInfo = {
time_cost: Math.max(0, ...cases.map((item) => Number(item.cpu_time) || 0)),
memory_cost: Math.max(0, ...cases.map((item) => Number(item.memory) || 0)),
score: 0,
}
if (result === JudgeStatus.ACCEPTED) {
const rules = astRulesForLanguage(
row.problem.astRules,
row.submission.language,
)
if (rules.length > 0) {
const ast = await checkAst(
row.submission.code,
row.submission.language,
rules,
)
if (!ast.passed) {
result = JudgeStatus.AST_CHECK_FAILED
statisticInfo.ast_results = ast.results
}
}
}
}
const saved = await persistResult(
row.submission.id,
row.problem.id,
row.submission.userId,
row.problem.displayId,
result,
info,
statisticInfo,
)
if (!saved) return
await publishSubmissionUpdate(row.submission.userId, {
type: "submission_update",
submission_id: row.submission.id,
result,
status: "finished",
time_cost:
typeof statisticInfo.time_cost === "number"
? statisticInfo.time_cost
: undefined,
memory_cost:
typeof statisticInfo.memory_cost === "number"
? statisticInfo.memory_cost
: undefined,
score:
typeof statisticInfo.score === "number" ? statisticInfo.score : undefined,
})
} catch (error) {
console.error(`Failed to judge submission ${row.submission.id}`, error)
await markSystemError(row.submission.id, row.submission.userId, error)
}
}

View File

@@ -0,0 +1,20 @@
export const JudgeStatus = {
COMPILE_ERROR: -2,
WRONG_ANSWER: -1,
ACCEPTED: 0,
CPU_TIME_LIMIT_EXCEEDED: 1,
REAL_TIME_LIMIT_EXCEEDED: 2,
MEMORY_LIMIT_EXCEEDED: 3,
RUNTIME_ERROR: 4,
SYSTEM_ERROR: 5,
PENDING: 6,
JUDGING: 7,
PARTIALLY_ACCEPTED: 8,
AST_CHECK_FAILED: 10,
} as const
export type JudgeStatusValue = (typeof JudgeStatus)[keyof typeof JudgeStatus]
export function isAccepted(result: number) {
return result === JudgeStatus.ACCEPTED || result === JudgeStatus.AST_CHECK_FAILED
}

View File

@@ -0,0 +1,12 @@
export function parseProblemTemplate(template: string) {
const section = (name: string) =>
template.match(
new RegExp(`//${name} BEGIN\\n([\\s\\S]+?)//${name} END`),
)?.[1] ?? ""
return {
prepend: section("PREPEND"),
template: section("TEMPLATE"),
append: section("APPEND"),
}
}