|
|
@@ -0,0 +1,374 @@
|
|
|
+package space.anyi.codesandbox;
|
|
|
+
|
|
|
+import org.slf4j.Logger;
|
|
|
+import org.slf4j.LoggerFactory;
|
|
|
+import space.anyi.serve.entity.contest.Submission;
|
|
|
+
|
|
|
+import java.io.*;
|
|
|
+import java.nio.charset.StandardCharsets;
|
|
|
+import java.nio.file.Files;
|
|
|
+import java.nio.file.Path;
|
|
|
+import java.util.*;
|
|
|
+import java.util.concurrent.*;
|
|
|
+
|
|
|
+/**
|
|
|
+ * @fileName: JVMNativeCodeSandBox
|
|
|
+ * @projectName: serve
|
|
|
+ * @package: space.anyi.codesandbox
|
|
|
+ * @author: yangyi
|
|
|
+ * @date:8/9/2026 7:29 pm
|
|
|
+ * @description: 使用JVM实现判题沙箱
|
|
|
+ */
|
|
|
+public class JVMNativeCodeSandBox implements CodeSandBox {
|
|
|
+ private final static Logger log = LoggerFactory.getLogger(JVMNativeCodeSandBox.class);
|
|
|
+
|
|
|
+ /** 编译超时时间(毫秒) */
|
|
|
+ private static final long COMPILE_TIMEOUT_MS = 10_000L;
|
|
|
+
|
|
|
+ /** 程序输出截断上限(字节),防止恶意输出耗尽内存 */
|
|
|
+ private static final int MAX_OUTPUT_LENGTH = 1024 * 1024;
|
|
|
+
|
|
|
+ /** 异步IO线程池,守护线程避免阻塞JVM退出 */
|
|
|
+ private static final ExecutorService IO_EXECUTOR = Executors.newCachedThreadPool(r -> {
|
|
|
+ Thread thread = new Thread(r, "codesandbox-io");
|
|
|
+ thread.setDaemon(true);
|
|
|
+ return thread;
|
|
|
+ });
|
|
|
+
|
|
|
+ /** 最大并发判题数,受宿主内存约束 */
|
|
|
+ private static final int MAX_CONCURRENT = 10;
|
|
|
+
|
|
|
+ /** 获取并发许可的超时(毫秒),超时返回SE */
|
|
|
+ private static final int CONCURRENCY_ACQUIRE_TIMEOUT_MS = 5_000;
|
|
|
+
|
|
|
+ /** 并发控制信号量 */
|
|
|
+ private static final Semaphore CONCURRENCY_LIMIT = new Semaphore(MAX_CONCURRENT);
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public CodeResponse process(CodeContext codeContext) {
|
|
|
+ //0.并发控制:达到上限时超时返回SE,避免进程爆炸
|
|
|
+ boolean acquired;
|
|
|
+ try {
|
|
|
+ acquired = CONCURRENCY_LIMIT.tryAcquire(CONCURRENCY_ACQUIRE_TIMEOUT_MS, TimeUnit.MILLISECONDS);
|
|
|
+ } catch (InterruptedException e) {
|
|
|
+ Thread.currentThread().interrupt();
|
|
|
+ return getCodeResponse(codeContext.id(), Submission.STATUS_SE, "系统繁忙");
|
|
|
+ }
|
|
|
+ if (!acquired) {
|
|
|
+ return getCodeResponse(codeContext.id(), Submission.STATUS_SE, "系统繁忙");
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ /**
|
|
|
+ * 1.将代码存储到临时文件
|
|
|
+ * 2.编译代码
|
|
|
+ * 3.执行代码
|
|
|
+ * 4.清理临时文件
|
|
|
+ * 5.统计执行结果
|
|
|
+ */
|
|
|
+ //1.将代码存储到临时文件
|
|
|
+ String currentDir = System.getProperty("user.dir");
|
|
|
+ String uuid = UUID.randomUUID().toString();
|
|
|
+ File workDir = new File(currentDir + File.separator + "code" + File.separator + uuid);
|
|
|
+ File codeFile = new File(workDir + File.separator + "Main.java");
|
|
|
+ try {
|
|
|
+ Files.createDirectories(workDir.toPath());
|
|
|
+ Files.createFile(codeFile.toPath());
|
|
|
+ try (BufferedOutputStream bufferedOutputStream = new BufferedOutputStream(new FileOutputStream(codeFile))) {
|
|
|
+ bufferedOutputStream.write(codeContext.code().getBytes(StandardCharsets.UTF_8));
|
|
|
+ bufferedOutputStream.flush();
|
|
|
+ }
|
|
|
+ } catch (IOException e) {
|
|
|
+ log.error(e.getMessage());
|
|
|
+ return getCodeResponse(codeContext.id(), Submission.STATUS_SE, "创建临时目录失败");
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ CodeResponse compileResponse = compile(codeContext, workDir);
|
|
|
+ if (compileResponse != null) {
|
|
|
+ return compileResponse;
|
|
|
+ }
|
|
|
+ return execute(codeContext, workDir);
|
|
|
+ } finally {
|
|
|
+ //4.清理临时文件
|
|
|
+ try {
|
|
|
+ clearTempFile(workDir.toPath());
|
|
|
+ } catch (IOException e) {
|
|
|
+ log.error("删除临时文件失败:{}", e.getMessage());
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } finally {
|
|
|
+ CONCURRENCY_LIMIT.release();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 2.编译代码
|
|
|
+ * @param codeContext 判题上下文
|
|
|
+ * @param workDir 工作目录
|
|
|
+ * @return 编译失败时返回对应的CodeResponse,编译成功返回null
|
|
|
+ */
|
|
|
+ private CodeResponse compile(CodeContext codeContext, File workDir) {
|
|
|
+ Process process = null;
|
|
|
+ CompletableFuture<String> compileOutFuture = null;
|
|
|
+ try {
|
|
|
+ process = new ProcessBuilder(resolveJavacPath(), "-encoding", "utf8", "Main.java")
|
|
|
+ //重定向错误输出流到标准输出
|
|
|
+ .redirectErrorStream(true)
|
|
|
+ //设置工作目录
|
|
|
+ .directory(workDir)
|
|
|
+ .start();
|
|
|
+ //异步读取编译输出,防止主线程阻塞
|
|
|
+ InputStream compileInputStream = process.getInputStream();
|
|
|
+ compileOutFuture = CompletableFuture.supplyAsync(() -> readStream(compileInputStream), IO_EXECUTOR);
|
|
|
+ boolean compiled = process.waitFor(COMPILE_TIMEOUT_MS, TimeUnit.MILLISECONDS);
|
|
|
+ if (!compiled) {
|
|
|
+ process.destroyForcibly();
|
|
|
+ return getCodeResponse(codeContext.id(), Submission.STATUS_SE, "编译超时");
|
|
|
+ }
|
|
|
+ String message = compileOutFuture.get(2, TimeUnit.SECONDS);
|
|
|
+ if (process.exitValue() != 0) {
|
|
|
+ return getCodeResponse(codeContext.id(), Submission.STATUS_CE, message);
|
|
|
+ }
|
|
|
+ } catch (IOException | InterruptedException e) {
|
|
|
+ if (process != null) {
|
|
|
+ process.destroyForcibly();
|
|
|
+ }
|
|
|
+ if (compileOutFuture != null) {
|
|
|
+ compileOutFuture.cancel(true);
|
|
|
+ }
|
|
|
+ log.error(e.getMessage());
|
|
|
+ return getCodeResponse(codeContext.id(), Submission.STATUS_SE, "编译异常:" + e.getMessage());
|
|
|
+ } catch (ExecutionException | TimeoutException e) {
|
|
|
+ if (process != null) {
|
|
|
+ process.destroyForcibly();
|
|
|
+ }
|
|
|
+ if (compileOutFuture != null) {
|
|
|
+ compileOutFuture.cancel(true);
|
|
|
+ }
|
|
|
+ log.error(e.getMessage());
|
|
|
+ return getCodeResponse(codeContext.id(), Submission.STATUS_SE, e.getMessage());
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 3.执行代码
|
|
|
+ * @param codeContext 判题上下文
|
|
|
+ * @param workDir 工作目录
|
|
|
+ * @return 判题结果
|
|
|
+ */
|
|
|
+ private CodeResponse execute(CodeContext codeContext, File workDir) {
|
|
|
+ String javaPath = resolveJavaPath();
|
|
|
+ List<TestCaseResponse> testCaseResponses = new ArrayList<>();
|
|
|
+ for (TestCase testCase : codeContext.testCases()) {
|
|
|
+ long startTime = System.nanoTime();
|
|
|
+ Process process = null;
|
|
|
+ CompletableFuture<String> stdoutFuture = null;
|
|
|
+ CompletableFuture<String> stderrFuture = null;
|
|
|
+ CompletableFuture<Void> stdinFuture = null;
|
|
|
+ try {
|
|
|
+ process = new ProcessBuilder(javaPath, "-Xmx" + codeContext.maxMemory() + "k", "-Dfile.encoding=utf8", "Main").directory(workDir).start();
|
|
|
+ //3.1异步读取标准输出,防止子进程大量输出阻塞主线程
|
|
|
+ InputStream stdoutInputStream = process.getInputStream();
|
|
|
+ stdoutFuture = CompletableFuture.supplyAsync(() -> readStream(stdoutInputStream), IO_EXECUTOR);
|
|
|
+ //3.2异步读取错误输出
|
|
|
+ InputStream stderrInputStream = process.getErrorStream();
|
|
|
+ stderrFuture = CompletableFuture.supplyAsync(() -> readStream(stderrInputStream), IO_EXECUTOR);
|
|
|
+ //3.3异步写入标准输入,写入后关闭以通知EOF
|
|
|
+ OutputStream stdinOutputStream = process.getOutputStream();
|
|
|
+ stdinFuture = CompletableFuture.runAsync(() -> writeInputAndClose(stdinOutputStream, testCase.input()), IO_EXECUTOR);
|
|
|
+ //3.4带超时等待进程结束,防止死循环导致资源枯竭
|
|
|
+ boolean finished = process.waitFor(codeContext.maxTime(), TimeUnit.MILLISECONDS);
|
|
|
+ long elapsedMs = (System.nanoTime() - startTime) / 1_000_000;
|
|
|
+ if (!finished) {
|
|
|
+ //销毁死循环进程
|
|
|
+ process.destroyForcibly();
|
|
|
+ String out = stdoutFuture.get(1, TimeUnit.SECONDS);
|
|
|
+ String err = stderrFuture.get(1, TimeUnit.SECONDS);
|
|
|
+ stdinFuture.cancel(true);
|
|
|
+ testCaseResponses.add(new TestCaseResponse(testCase.id(), Submission.STATUS_TLE, elapsedMs, 0L));
|
|
|
+ return new CodeResponse(codeContext.id(), Submission.STATUS_TLE, elapsedMs, 0L, testCaseResponses, "运行时间超限");
|
|
|
+ }
|
|
|
+ String out = stdoutFuture.get(2, TimeUnit.SECONDS);
|
|
|
+ String err = stderrFuture.get(2, TimeUnit.SECONDS);
|
|
|
+ stdinFuture.get(2, TimeUnit.SECONDS);
|
|
|
+ //3.5实际运行时间与限制时间比较
|
|
|
+ if (elapsedMs > codeContext.maxTime()) {
|
|
|
+ testCaseResponses.add(new TestCaseResponse(testCase.id(), Submission.STATUS_TLE, elapsedMs, 0L));
|
|
|
+ return new CodeResponse(codeContext.id(), Submission.STATUS_TLE, elapsedMs, 0L, testCaseResponses, "运行时间超限");
|
|
|
+ }
|
|
|
+ //todo:3.6获取代码的内存使用量
|
|
|
+ //todo:3.7实际内存使用量与限制内存使用量比较
|
|
|
+ if (process.exitValue() == 0) {
|
|
|
+ //3.8输出与预期进行比较
|
|
|
+ if (outputMatches(out, testCase.output())) {
|
|
|
+ testCaseResponses.add(new TestCaseResponse(testCase.id(), Submission.STATUS_AC, elapsedMs, 0L));
|
|
|
+ } else {
|
|
|
+ testCaseResponses.add(new TestCaseResponse(testCase.id(), Submission.STATUS_WA, elapsedMs, 0L));
|
|
|
+ return getCodeResponse(codeContext.id(), Submission.STATUS_WA, "输出与预期不符");
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ //输出为空时退回标准输出,否则展示错误输出
|
|
|
+ String message = err.isEmpty() ? out : err;
|
|
|
+ return getCodeResponse(codeContext.id(), Submission.STATUS_RE, message);
|
|
|
+ }
|
|
|
+ } catch (IOException | InterruptedException e) {
|
|
|
+ if (process != null) {
|
|
|
+ process.destroyForcibly();
|
|
|
+ }
|
|
|
+ cancel(stdoutFuture, stderrFuture, stdinFuture);
|
|
|
+ log.error(e.getMessage());
|
|
|
+ return getCodeResponse(codeContext.id(), Submission.STATUS_SE, e.getMessage());
|
|
|
+ } catch (ExecutionException | TimeoutException e) {
|
|
|
+ if (process != null) {
|
|
|
+ process.destroyForcibly();
|
|
|
+ }
|
|
|
+ cancel(stdoutFuture, stderrFuture, stdinFuture);
|
|
|
+ log.error(e.getMessage());
|
|
|
+ return getCodeResponse(codeContext.id(), Submission.STATUS_SE, e.getMessage());
|
|
|
+ }
|
|
|
+ }
|
|
|
+ //5.统计执行结果
|
|
|
+ Optional<TestCaseResponse> maxUsedTimeResponse = testCaseResponses.stream().max((v1, v2) -> v1.usedTime().compareTo(v2.usedTime()));
|
|
|
+ Optional<TestCaseResponse> minUsedMemoryResponse = testCaseResponses.stream().min((v1, v2) -> v1.usedMemory().compareTo(v2.usedMemory()));
|
|
|
+ return new CodeResponse(
|
|
|
+ codeContext.id(),
|
|
|
+ Submission.STATUS_AC,
|
|
|
+ maxUsedTimeResponse.orElse(new TestCaseResponse(null, null, codeContext.maxTime(), null)).usedTime(),
|
|
|
+ minUsedMemoryResponse.orElse(new TestCaseResponse(null, null, null, codeContext.maxMemory())).usedMemory(),
|
|
|
+ testCaseResponses,
|
|
|
+ "");
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 读取输入流为UTF-8字符串,保留换行等全部字符,超过上限即停止读取,防止恶意输出耗尽内存
|
|
|
+ * @param inputStream 输入流
|
|
|
+ * @return 读取到的内容
|
|
|
+ */
|
|
|
+ private String readStream(InputStream inputStream) {
|
|
|
+ StringBuilder builder = new StringBuilder();
|
|
|
+ try (Reader reader = new InputStreamReader(inputStream, StandardCharsets.UTF_8)) {
|
|
|
+ char[] buffer = new char[8192];
|
|
|
+ int len;
|
|
|
+ while (builder.length() < MAX_OUTPUT_LENGTH && (len = reader.read(buffer)) != -1) {
|
|
|
+ int allowed = Math.min(len, MAX_OUTPUT_LENGTH - builder.length());
|
|
|
+ builder.append(buffer, 0, allowed);
|
|
|
+ }
|
|
|
+ } catch (IOException e) {
|
|
|
+ log.error(e.getMessage());
|
|
|
+ }
|
|
|
+ return builder.toString();
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 写入测试输入并关闭输出流,关闭以通知EOF使按EOF终止的程序正常退出
|
|
|
+ * @param outputStream 进程标准输入
|
|
|
+ * @param input 测试输入
|
|
|
+ */
|
|
|
+ private void writeInputAndClose(OutputStream outputStream, String input) {
|
|
|
+ try (BufferedWriter bufferedWriter = new BufferedWriter(new OutputStreamWriter(outputStream, StandardCharsets.UTF_8))) {
|
|
|
+ bufferedWriter.write(input + "\r\n");
|
|
|
+ bufferedWriter.flush();
|
|
|
+ } catch (IOException e) {
|
|
|
+ log.error(e.getMessage());
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 比较实际输出与预期输出,忽略行尾空白与末尾空行
|
|
|
+ * @param actual 实际输出
|
|
|
+ * @param expected 预期输出
|
|
|
+ * @return 是否匹配
|
|
|
+ */
|
|
|
+ private boolean outputMatches(String actual, String expected) {
|
|
|
+ return normalizeOutput(actual).equals(normalizeOutput(expected));
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 归一化输出:按行切分,修剪行尾空白,丢弃末尾空行
|
|
|
+ * @param output 原始输出
|
|
|
+ * @return 归一化后的行列表
|
|
|
+ */
|
|
|
+ private List<String> normalizeOutput(String output) {
|
|
|
+ List<String> lines = new ArrayList<>();
|
|
|
+ for (String line : output.split("\n", -1)) {
|
|
|
+ int end = line.length();
|
|
|
+ while (end > 0 && (line.charAt(end - 1) == ' ' || line.charAt(end - 1) == '\t' || line.charAt(end - 1) == '\r')) {
|
|
|
+ end--;
|
|
|
+ }
|
|
|
+ lines.add(line.substring(0, end));
|
|
|
+ }
|
|
|
+ while (!lines.isEmpty() && lines.get(lines.size() - 1).isEmpty()) {
|
|
|
+ lines.remove(lines.size() - 1);
|
|
|
+ }
|
|
|
+ return lines;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 取消指定任务
|
|
|
+ * @param futures 任务列表
|
|
|
+ */
|
|
|
+ private void cancel(CompletableFuture<?>... futures) {
|
|
|
+ for (CompletableFuture<?> future : futures) {
|
|
|
+ if (future != null) {
|
|
|
+ future.cancel(true);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 删除临时目录
|
|
|
+ * @param path
|
|
|
+ * @return
|
|
|
+ * @throws IOException
|
|
|
+ */
|
|
|
+ private boolean clearTempFile(Path path) throws IOException {
|
|
|
+ File workDir = path.toFile();
|
|
|
+ File[] files = workDir.listFiles();
|
|
|
+ if (files == null) {
|
|
|
+ return Files.deleteIfExists(path);
|
|
|
+ }
|
|
|
+ for (File file : files) {
|
|
|
+ if (file.isDirectory()) {
|
|
|
+ clearTempFile(file.toPath());
|
|
|
+ } else {
|
|
|
+ Files.deleteIfExists(file.toPath());
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return Files.deleteIfExists(path);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ *
|
|
|
+ * @param id
|
|
|
+ * @param status
|
|
|
+ * @param message
|
|
|
+ * @return
|
|
|
+ */
|
|
|
+ private CodeResponse getCodeResponse(Long id, String status, String message) {
|
|
|
+ return new CodeResponse(id, status, 0L, 0L, Collections.emptyList(), message);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 解析javac路径,优先使用JAVA_HOME,回退到当前运行时
|
|
|
+ * @return
|
|
|
+ */
|
|
|
+ private String resolveJavacPath() {
|
|
|
+ String javaHome = System.getenv("JAVA_HOME");
|
|
|
+ if (javaHome == null || javaHome.isEmpty()) {
|
|
|
+ javaHome = System.getProperty("java.home");
|
|
|
+ }
|
|
|
+ return javaHome + File.separator + "bin" + File.separator + "javac";
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 解析java路径,优先使用JAVA_HOME,回退到当前运行时
|
|
|
+ * @return
|
|
|
+ */
|
|
|
+ private String resolveJavaPath() {
|
|
|
+ String javaHome = System.getenv("JAVA_HOME");
|
|
|
+ if (javaHome == null || javaHome.isEmpty()) {
|
|
|
+ javaHome = System.getProperty("java.home");
|
|
|
+ }
|
|
|
+ return javaHome + File.separator + "bin" + File.separator + "java";
|
|
|
+ }
|
|
|
+}
|