This commit is contained in:
2026-07-26 07:20:16 -06:00
parent 66fac1f66d
commit 224960ac0f
4 changed files with 57 additions and 9 deletions

View File

@@ -79,6 +79,10 @@ class DispatcherBase(object):
class JudgeDispatcher(DispatcherBase): class JudgeDispatcher(DispatcherBase):
# 是否占用 JudgeServer 槽位。判完后只有占过槽位的才需要唤醒等待队列,
# 否则会把队首任务 pop 出来又原地退回SQLJudgeDispatcher 在 worker 内判题,不占槽位)
uses_judge_server = True
def __init__(self, submission_id, problem_id): def __init__(self, submission_id, problem_id):
super().__init__() super().__init__()
self.submission = Submission.objects.get(id=submission_id) self.submission = Submission.objects.get(id=submission_id)
@@ -216,8 +220,9 @@ class JudgeDispatcher(DispatcherBase):
else: else:
self.update_problem_status() self.update_problem_status()
# 至此判题结束,尝试处理任务队列中剩余的任务 # 至此判题结束,释放了 JudgeServer 槽位,尝试处理任务队列中剩余的任务
process_pending_task() if self.uses_judge_server:
process_pending_task()
def update_problem_status_rejudge(self): def update_problem_status_rejudge(self):
result = str(self.submission.result) result = str(self.submission.result)

View File

@@ -23,6 +23,9 @@ class SQLProblemConfigError(Exception):
class SQLJudgeDispatcher(JudgeDispatcher): class SQLJudgeDispatcher(JudgeDispatcher):
# 在 worker 内用 sqlite3 判题,不经过 ChooseJudgeServer也就没有槽位可释放
uses_judge_server = False
def judge(self): def judge(self):
Submission.objects.filter(id=self.submission.id).update(result=JudgeStatus.JUDGING) Submission.objects.filter(id=self.submission.id).update(result=JudgeStatus.JUDGING)
self._push_status(JudgeStatus.JUDGING, "judging") self._push_status(JudgeStatus.JUDGING, "judging")
@@ -56,6 +59,15 @@ class SQLJudgeDispatcher(JudgeDispatcher):
return return
cases.append(case) cases.append(case)
# 判题给出的中文提示(授权拒绝/超时/内存/无结果集)只存在测试点的 error_message 里,
# 前端只读 statistic_info.err_info这里把首个失败测试点的提示提上来否则学生看不到原因
failed = next((c for c in cases if c["result"] != JudgeStatus.ACCEPTED), None)
if failed and failed["error_message"]:
self.submission.statistic_info["err_info"] = failed["error_message"]
else:
# rejudge 时清掉上一轮的残留提示
self.submission.statistic_info.pop("err_info", None)
self._process_judge_result({"err": None, "data": cases}) self._process_judge_result({"err": None, "data": cases})
def _system_error(self, message): def _system_error(self, message):

View File

@@ -3,7 +3,8 @@
查询题mode="query")比对最后一条 SELECT 的结果集; 查询题mode="query")比对最后一条 SELECT 的结果集;
增删改题mode="modify")比对执行后所有用户表的最终状态。 增删改题mode="modify")比对执行后所有用户表的最终状态。
学生 SQL 通过 authorizer禁 ATTACH/PRAGMA查询题只读 学生 SQL 通过 authorizer禁 ATTACH/PRAGMA查询题只读
progress_handler墙钟超时和 max_page_count内存上限三重防护 progress_handler墙钟超时和 max_page_count + SQLITE_LIMIT_LENGTH(内存上限)三重防护
受信脚本(初始化/标准答案)不受超时和只读限制,但同样禁止 ATTACH/DETACH。
""" """
import sqlite3 import sqlite3
@@ -26,6 +27,16 @@ _SYNTAX_ERROR_MARKERS = ("syntax error", "unrecognized token", "incomplete input
# 两种模式都禁止的授权码:挂载外部库 / 数据库参数 # 两种模式都禁止的授权码:挂载外部库 / 数据库参数
_DENIED_ALWAYS = {sqlite3.SQLITE_ATTACH, sqlite3.SQLITE_DETACH, sqlite3.SQLITE_PRAGMA} _DENIED_ALWAYS = {sqlite3.SQLITE_ATTACH, sqlite3.SQLITE_DETACH, sqlite3.SQLITE_PRAGMA}
# 受信脚本(初始化/标准答案也必须禁止挂载外部库ATTACH 能在服务器上读写任意 SQLite 文件,
# 而这些脚本在保存/预览题目时跑在 Django 请求进程里,等于把出题权限提成任意文件读写。
_TRUSTED_DENIED = {sqlite3.SQLITE_ATTACH, sqlite3.SQLITE_DETACH}
_ATTACH_DENIED_HINT = "脚本中禁止使用 ATTACH/DETACH 挂载外部数据库"
def _trusted_authorizer(action, arg1, arg2, db_name, trigger):
return sqlite3.SQLITE_DENY if action in _TRUSTED_DENIED else sqlite3.SQLITE_OK
# 查询题允许的授权码(白名单外一律拒绝,防先 INSERT 伪造数据再 SELECT # 查询题允许的授权码(白名单外一律拒绝,防先 INSERT 伪造数据再 SELECT
_QUERY_MODE_ALLOWED = {getattr(sqlite3, name) for name in ("SQLITE_SELECT", "SQLITE_READ", "SQLITE_FUNCTION", "SQLITE_RECURSIVE", "SQLITE_TRANSACTION") if hasattr(sqlite3, name)} _QUERY_MODE_ALLOWED = {getattr(sqlite3, name) for name in ("SQLITE_SELECT", "SQLITE_READ", "SQLITE_FUNCTION", "SQLITE_RECURSIVE", "SQLITE_TRANSACTION") if hasattr(sqlite3, name)}
@@ -84,10 +95,17 @@ def _canonical_row(row):
def _new_db(memory_limit_mb): def _new_db(memory_limit_mb):
limit_mb = max(int(memory_limit_mb), 1)
conn = sqlite3.connect(":memory:", isolation_level=None) # autocommit脚本行为可预期 conn = sqlite3.connect(":memory:", isolation_level=None) # autocommit脚本行为可预期
conn.execute("PRAGMA page_size=4096") conn.execute("PRAGMA page_size=4096")
# 4096B/页 × 256 页/MB超限报 "database or disk is full" # 4096B/页 × 256 页/MB超限报 "database or disk is full"
conn.execute(f"PRAGMA max_page_count={max(int(memory_limit_mb), 1) * 256}") conn.execute(f"PRAGMA max_page_count={limit_mb * 256}")
# max_page_count 只约束数据库页,管不住查询期的单值分配:默认 1GB 上限下
# 一条 SELECT hex(zeroblob(5e8)) 就能在 worker 进程里吃掉 GB 级内存且秒级返回,
# progress_handler 的指令粒度也拦不到(分配发生在单条 opcode 内)。
conn.setlimit(sqlite3.SQLITE_LIMIT_LENGTH, limit_mb * 1024 * 1024)
# 整条连接生命周期内禁止 ATTACH/DETACH学生 SQL 由 _run_student 换上更严的 authorizer
conn.set_authorizer(_trusted_authorizer)
return conn return conn
@@ -122,6 +140,16 @@ def _dump_tables(conn):
return state return state
def _trusted_error_text(e):
"""受信脚本的 sqlite 错误转出题人能看懂的提示。"""
msg = str(e)
if "not authorized" in msg or "prohibited" in msg:
return _ATTACH_DENIED_HINT
if "interrupted" in msg:
return "超时"
return _truncate(msg)
def _execute_trusted(conn, script, deadline, error_prefix): def _execute_trusted(conn, script, deadline, error_prefix):
"""执行受信脚本,任何失败都是出题问题 → SYSTEM_ERROR。""" """执行受信脚本,任何失败都是出题问题 → SYSTEM_ERROR。"""
conn.set_progress_handler(lambda: 1 if time.monotonic() > deadline else 0, PROGRESS_STEP) conn.set_progress_handler(lambda: 1 if time.monotonic() > deadline else 0, PROGRESS_STEP)
@@ -130,7 +158,7 @@ def _execute_trusted(conn, script, deadline, error_prefix):
except SQLCaseError as e: except SQLCaseError as e:
raise SQLCaseError(JudgeStatus.SYSTEM_ERROR, f"{error_prefix}: {e.message}") raise SQLCaseError(JudgeStatus.SYSTEM_ERROR, f"{error_prefix}: {e.message}")
except sqlite3.Error as e: except sqlite3.Error as e:
raise SQLCaseError(JudgeStatus.SYSTEM_ERROR, f"{error_prefix}: {_truncate(e)}") raise SQLCaseError(JudgeStatus.SYSTEM_ERROR, f"{error_prefix}: {_trusted_error_text(e)}")
finally: finally:
conn.set_progress_handler(None, PROGRESS_STEP) conn.set_progress_handler(None, PROGRESS_STEP)
@@ -159,6 +187,9 @@ def _run_student(conn, script, mode, deadline):
raise SQLCaseError(JudgeStatus.CPU_TIME_LIMIT_EXCEEDED, "SQL 执行超时") raise SQLCaseError(JudgeStatus.CPU_TIME_LIMIT_EXCEEDED, "SQL 执行超时")
if "database or disk is full" in msg: if "database or disk is full" in msg:
raise SQLCaseError(JudgeStatus.MEMORY_LIMIT_EXCEEDED, "数据量超出内存限制") raise SQLCaseError(JudgeStatus.MEMORY_LIMIT_EXCEEDED, "数据量超出内存限制")
# SQLITE_LIMIT_LENGTH 触顶,如 zeroblob/group_concat 构造出的超大单值
if "too big" in msg:
raise SQLCaseError(JudgeStatus.MEMORY_LIMIT_EXCEEDED, "单个数据值超出内存限制")
if "not authorized" in msg or "prohibited" in msg: if "not authorized" in msg or "prohibited" in msg:
hint = denied_hints[-1] if denied_hints else "本题禁止使用该语句" hint = denied_hints[-1] if denied_hints else "本题禁止使用该语句"
raise SQLCaseError(JudgeStatus.RUNTIME_ERROR, hint) raise SQLCaseError(JudgeStatus.RUNTIME_ERROR, hint)
@@ -167,7 +198,8 @@ def _run_student(conn, script, mode, deadline):
raise SQLCaseError(JudgeStatus.RUNTIME_ERROR, _truncate(msg)) raise SQLCaseError(JudgeStatus.RUNTIME_ERROR, _truncate(msg))
finally: finally:
conn.set_progress_handler(None, PROGRESS_STEP) conn.set_progress_handler(None, PROGRESS_STEP)
conn.set_authorizer(None) # 还原连接级的 ATTACH/DETACH 防护,而不是彻底放开
conn.set_authorizer(_trusted_authorizer)
if mode == "query": if mode == "query":
return last_result return last_result
@@ -322,8 +354,7 @@ def build_display(init_sql, ref_sql, mode, *, memory_limit_mb=64):
} }
cursor.close() cursor.close()
except sqlite3.Error as e: except sqlite3.Error as e:
msg = "超时" if "interrupted" in str(e) else _truncate(e) raise SQLCaseError(JudgeStatus.SYSTEM_ERROR, f"标准答案执行失败: {_trusted_error_text(e)}")
raise SQLCaseError(JudgeStatus.SYSTEM_ERROR, f"标准答案执行失败: {msg}")
finally: finally:
conn.set_progress_handler(None, PROGRESS_STEP) conn.set_progress_handler(None, PROGRESS_STEP)
if expected is None: if expected is None:

View File

@@ -676,8 +676,8 @@ class TopACTrendAPI(APIView):
class SQLTestCasePreviewAPI(APIView): class SQLTestCasePreviewAPI(APIView):
@validate_serializer(SQLTestCasePreviewSerializer)
@problem_permission_required @problem_permission_required
@validate_serializer(SQLTestCasePreviewSerializer)
def post(self, request): def post(self, request):
data = request.data data = request.data
try: try: