Parcourir la source

# feat: 基于Docker的代码沙箱实现(容器隔离/编译执行/资源限制/并发限流)

yangyi il y a 2 semaines
Parent
commit
5e7286f382

+ 3 - 0
build.gradle.kts

@@ -42,6 +42,9 @@ dependencies {
     implementation("io.jsonwebtoken:jjwt-api:0.12.6")
     runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.6")
     runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.12.6")
+    // Docker Java SDK
+    implementation("com.github.docker-java:docker-java:3.7.1")
+    implementation("com.github.docker-java:docker-java-transport-httpclient5:3.7.1")
     annotationProcessor("org.springframework.boot:spring-boot-configuration-processor")
     testImplementation("org.springframework.boot:spring-boot-starter-test")
     testImplementation("org.springframework.security:spring-security-test")

+ 422 - 0
src/main/java/space/anyi/codesandbox/DockerCodeSandBox.java

@@ -0,0 +1,422 @@
+package space.anyi.codesandbox;
+
+import com.github.dockerjava.api.DockerClient;
+import com.github.dockerjava.api.async.ResultCallback;
+import com.github.dockerjava.api.command.CreateContainerResponse;
+import com.github.dockerjava.api.command.ExecCreateCmdResponse;
+import com.github.dockerjava.api.model.Capability;
+import com.github.dockerjava.api.model.Frame;
+import com.github.dockerjava.api.model.HostConfig;
+import com.github.dockerjava.api.model.StreamType;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import space.anyi.serve.entity.contest.Submission;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.*;
+import java.util.concurrent.Semaphore;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * @fileName: DockerCodeSandBox
+ * @projectName: serve
+ * @package: space.anyi.codesandbox
+ * @author: yangyi
+ * @date:14/10/2026 4:44 pm
+ * @description: 基于docker实现代码沙箱
+ */
+public class DockerCodeSandBox implements CodeSandBox {
+    private static final Logger log = LoggerFactory.getLogger(DockerCodeSandBox.class);
+
+    private static final String JUDGE_IMAGE = "eclipse-temurin:17-jdk-alpine";
+    private static final String CODE_DIR = "/code";
+    private static final String SOURCE_FILE = "Main.java";
+    private static final long COMPILE_TIMEOUT_MS = 20_000;
+
+    /** 单次运行输出截断上限(字节),防止恶意输出耗尽内存 */
+    private static final int MAX_OUTPUT_LENGTH = 1024 * 1024;
+
+    /** 最大并发判题数,受宿主机 Docker 资源约束 */
+    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);
+
+    private DockerClient dockerClient;
+
+    /** 判题镜像是否已就绪,避免每次判题都查询 Docker daemon */
+    private volatile boolean judgeImageReady = false;
+
+    public DockerCodeSandBox() {
+    }
+
+    public DockerCodeSandBox(DockerClient dockerClient) {
+        this.dockerClient = dockerClient;
+    }
+
+    public void setDockerClient(DockerClient dockerClient) {
+        this.dockerClient = dockerClient;
+    }
+
+    @Override
+    public CodeResponse process(CodeContext codeContext) {
+        // 并发控制:达到上限时超时返回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 {
+            return doProcess(codeContext);
+        } catch (Exception e) {
+            log.error("判题系统异常", e);
+            return getCodeResponse(codeContext.id(), Submission.STATUS_SE, "系统异常:" + e.getMessage());
+        } finally {
+            CONCURRENCY_LIMIT.release();
+        }
+    }
+
+    private CodeResponse doProcess(CodeContext codeContext) {
+        ensureJudgeImageAvailable();
+        String containerId = null;
+        try {
+            containerId = createSandboxContainer(codeContext);
+            log.info("沙箱容器已创建: {}", containerId);
+
+            injectSource(containerId, codeContext.code());
+            CompileResult compileResult = compile(containerId);
+            if (compileResult.systemError()) {
+                log.error("编译系统异常: {}", compileResult.output());
+                return getCodeResponse(codeContext.id(), Submission.STATUS_SE, compileResult.output());
+            }
+            if (!compileResult.output().isEmpty()) {
+                log.warn("编译失败,判定 CE:\n{}", compileResult.output());
+                return getCodeResponse(codeContext.id(), Submission.STATUS_CE, compileResult.output());
+            }
+            log.info("编译通过");
+
+            List<TestCaseResponse> results = new ArrayList<>();
+            for (int i = 0; i < codeContext.testCases().size(); i++) {
+                TestCase tc = codeContext.testCases().get(i);
+                RunResult run = runCase(containerId, codeContext, tc);
+                String verdict = finalVerdict(run, tc);
+                results.add(new TestCaseResponse(tc.id(), verdict, run.durationMs(), 0L));
+                log.info("用例 {}/{} 判定: {} (耗时 {}ms, 退出码 {})",
+                        i + 1, codeContext.testCases().size(), verdict, run.durationMs(), run.exitCode());
+                if (!Submission.STATUS_AC.equals(verdict)) {
+                    // 出现首个非 AC 用例即返回,TLE 时容器已被杀死,后续用例无法继续
+                    return new CodeResponse(codeContext.id(), verdict, getMaxTime(results),
+                            getMinMemory(results), results, resolveMessage(verdict, run));
+                }
+            }
+            return new CodeResponse(codeContext.id(), Submission.STATUS_AC, getMaxTime(results),
+                    getMinMemory(results), results, "");
+        } finally {
+            removeContainerQuietly(containerId);
+        }
+    }
+
+    /**
+     * 强制删除容器,忽略清理过程中的异常
+     * @param containerId 容器 ID,为 null 时不做任何事
+     */
+    private void removeContainerQuietly(String containerId) {
+        if (containerId == null) {
+            return;
+        }
+        try {
+            dockerClient.removeContainerCmd(containerId).withForce(true).exec();
+            log.info("沙箱容器已清理: {}", containerId);
+        } catch (Exception e) {
+            log.error("清理容器失败", e);
+        }
+    }
+
+    private void ensureJudgeImageAvailable() {
+        if (judgeImageReady) {
+            return;
+        }
+        synchronized (this) {
+            if (judgeImageReady) {
+                return;
+            }
+            boolean present = dockerClient.listImagesCmd().exec().stream()
+                    .anyMatch(img -> img.getRepoTags() != null
+                            && Arrays.asList(img.getRepoTags()).contains(JUDGE_IMAGE));
+            if (!present) {
+                log.info("判题镜像 {} 不存在,开始拉取...", JUDGE_IMAGE);
+                try {
+                    dockerClient.pullImageCmd(JUDGE_IMAGE)
+                            .exec(new ResultCallback.Adapter<>()).awaitCompletion();
+                    log.info("判题镜像 {} 拉取完成", JUDGE_IMAGE);
+                } catch (InterruptedException e) {
+                    Thread.currentThread().interrupt();
+                    throw new IllegalStateException("判题镜像拉取被中断", e);
+                }
+            }
+            judgeImageReady = true;
+        }
+    }
+
+    private String createSandboxContainer(CodeContext codeContext) {
+        HostConfig hostConfig = buildSandboxHostConfig(codeContext);
+        // 容器主进程存活时间需覆盖全部用例的判题耗时,避免中途退出导致 exec 失败
+        long keepAliveSeconds = Math.max(300L,
+                codeContext.maxTime() * Math.max(1, codeContext.testCases().size()) / 1000L + 60L);
+        CreateContainerResponse container = dockerClient.createContainerCmd(JUDGE_IMAGE)
+                .withName("oj-sandbox-" + System.nanoTime())
+                .withHostConfig(hostConfig)
+                .withWorkingDir(CODE_DIR)
+                .withUser("nobody")
+                .withEnv("HOME=/tmp")
+                .withEntrypoint("sh", "-c")
+                .withCmd("sleep " + keepAliveSeconds)
+                .exec();
+        String containerId = container.getId();
+        try {
+            dockerClient.startContainerCmd(containerId).exec();
+        } catch (Exception e) {
+            // 启动失败时容器 ID 尚未交给调用方,需在此清理,避免容器泄漏
+            removeContainerQuietly(containerId);
+            throw e;
+        }
+        return containerId;
+    }
+
+    private HostConfig buildSandboxHostConfig(CodeContext codeContext) {
+        long memoryBytes = codeContext.maxMemory() * 1024L;
+        Map<String, String> tmpFs = Map.of(
+                CODE_DIR, "size=8m,mode=1777",
+                "/tmp", "size=8m,mode=1777");
+        return HostConfig.newHostConfig()
+                .withMemory(memoryBytes)
+                .withMemorySwap(memoryBytes * 2)
+                .withNanoCPUs(1_000_000_000L)
+                .withPidsLimit(64L)
+                .withNetworkMode("none")
+                .withCapDrop(Capability.ALL)
+                .withReadonlyRootfs(true)
+                .withTmpFs(tmpFs);
+    }
+
+    private void injectSource(String containerId, String source) {
+        String encoded = Base64.getEncoder()
+                .encodeToString(source.getBytes(StandardCharsets.UTF_8));
+        ExecCreateCmdResponse exec = dockerClient.execCreateCmd(containerId)
+                .withCmd("sh", "-c",
+                        "printf %s '" + encoded + "' | base64 -d > " + CODE_DIR + "/" + SOURCE_FILE)
+                .withAttachStdout(true)
+                .withAttachStderr(true)
+                .exec();
+        try {
+            boolean completed = dockerClient.execStartCmd(exec.getId())
+                    .withDetach(false)
+                    .exec(new ResultCallback.Adapter<>()).awaitCompletion(COMPILE_TIMEOUT_MS, TimeUnit.MILLISECONDS);
+            if (!completed) {
+                throw new IllegalStateException("源码注入超时");
+            }
+            log.info("源码 [{}] 已注入沙箱容器 ({} 字节)", SOURCE_FILE, source.length());
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            throw new IllegalStateException("源码注入被中断", e);
+        }
+    }
+
+    /**
+     * 编译执行结果
+     * @param output 编译输出,成功为空串
+     * @param systemError 是否为判题系统自身异常(超时/daemon 异常),而非选手代码的编译错误
+     */
+    private record CompileResult(String output, boolean systemError) {
+    }
+
+    private CompileResult compile(String containerId) {
+        ByteArrayOutputStream output = new ByteArrayOutputStream();
+        try {
+            ExecCreateCmdResponse exec = dockerClient.execCreateCmd(containerId)
+                    .withCmd("javac", "-encoding", "UTF-8", "-d", CODE_DIR,
+                            CODE_DIR + "/" + SOURCE_FILE)
+                    .withAttachStdout(true)
+                    .withAttachStderr(true)
+                    .exec();
+            boolean completed = dockerClient.execStartCmd(exec.getId())
+                    .withDetach(false)
+                    .exec(new ResultCallback.Adapter<Frame>() {
+                        @Override
+                        public void onNext(Frame frame) {
+                            output.writeBytes(frame.getPayload());
+                        }
+                    }).awaitCompletion(COMPILE_TIMEOUT_MS, TimeUnit.MILLISECONDS);
+            String message = output.toString(StandardCharsets.UTF_8);
+            if (!completed) {
+                return new CompileResult("编译超时: " + message, true);
+            }
+            Long exitCode = dockerClient.inspectExecCmd(exec.getId()).exec().getExitCodeLong();
+            if (exitCode == null) {
+                return new CompileResult("编译状态未知: " + message, true);
+            }
+            return new CompileResult(exitCode == 0 ? "" : message, false);
+        } catch (Exception e) {
+            log.error("编译执行异常", e);
+            return new CompileResult("编译系统异常: " + e.getMessage(), true);
+        }
+    }
+
+    /**
+     * 单个用例的运行结果
+     * @param stdout 标准输出
+     * @param stderr 标准错误输出
+     * @param exitCode 进程退出码,超时/异常为 -1
+     * @param durationMs 运行耗时(毫秒)
+     * @param verdict 用例判定,为 null 表示进程正常退出,待输出比对后定 AC/WA
+     */
+    public record RunResult(String stdout, String stderr, int exitCode, long durationMs, String verdict) {
+    }
+
+    private RunResult runCase(String containerId, CodeContext codeContext, TestCase tc) {
+        long startNanos = System.nanoTime();
+        // 按字节累积后统一解码,避免多字节 UTF-8 字符被 Docker Frame 边界切断
+        ByteArrayOutputStream stdout = new ByteArrayOutputStream();
+        ByteArrayOutputStream stderr = new ByteArrayOutputStream();
+        int[] bytes = {0};
+        boolean[] truncated = {false};
+
+        int heapKb = Math.max(8192, (int) (codeContext.maxMemory() - 16384));
+        String input = tc.input();
+        if (input != null && !input.endsWith("\n") && !input.endsWith("\r")) {
+            input = input + "\n";
+        }
+
+        try {
+            ExecCreateCmdResponse exec = dockerClient.execCreateCmd(containerId)
+                    .withAttachStdin(true)
+                    .withAttachStdout(true)
+                    .withAttachStderr(true)
+                    .withCmd("java", "-Xmx" + heapKb + "k", "-cp", CODE_DIR, "Main")
+                    .exec();
+
+            boolean timedOut = false;
+            ResultCallback.Adapter<Frame> callback = dockerClient.execStartCmd(exec.getId())
+                    .withDetach(false)
+                    .withStdIn(new ByteArrayInputStream((input == null ? "" : input).getBytes(StandardCharsets.UTF_8)))
+                    .exec(new ResultCallback.Adapter<Frame>() {
+                        @Override
+                        public void onNext(Frame frame) {
+                            byte[] payload = frame.getPayload();
+                            bytes[0] += payload.length;
+                            if (bytes[0] > MAX_OUTPUT_LENGTH) {
+                                truncated[0] = true;
+                                return;
+                            }
+                            if (frame.getStreamType() == StreamType.STDOUT
+                                    || frame.getStreamType() == StreamType.RAW) {
+                                stdout.writeBytes(payload);
+                            } else if (frame.getStreamType() == StreamType.STDERR) {
+                                stderr.writeBytes(payload);
+                            }
+                        }
+                    });
+
+            boolean completed = callback.awaitCompletion(codeContext.maxTime(), TimeUnit.MILLISECONDS);
+            if (!completed) {
+                try {
+                    dockerClient.killContainerCmd(containerId).withSignal("KILL").exec();
+                } catch (Exception e) {
+                    log.warn("杀死容器失败", e);
+                }
+                callback.close();
+                timedOut = true;
+            }
+
+            long durationMs = (System.nanoTime() - startNanos) / 1_000_000;
+            String out = stdout.toString(StandardCharsets.UTF_8);
+            String err = stderr.toString(StandardCharsets.UTF_8);
+
+            if (timedOut) {
+                return new RunResult(out, err, -1, durationMs, Submission.STATUS_TLE);
+            }
+            Long exitCodeLong = dockerClient.inspectExecCmd(exec.getId()).exec().getExitCodeLong();
+            int exitCode = exitCodeLong == null ? -1 : exitCodeLong.intValue();
+            if (truncated[0] || exitCode != 0) {
+                return new RunResult(out, err, exitCode, durationMs, Submission.STATUS_RE);
+            }
+            return new RunResult(out, err, exitCode, durationMs, null);
+        } catch (Exception e) {
+            log.error("用例运行系统异常", e);
+            long durationMs = (System.nanoTime() - startNanos) / 1_000_000;
+            return new RunResult(stdout.toString(StandardCharsets.UTF_8),
+                    stderr.toString(StandardCharsets.UTF_8), -1, durationMs, Submission.STATUS_SE);
+        }
+    }
+
+    private String finalVerdict(RunResult run, TestCase tc) {
+        if (run.verdict() != null) {
+            return run.verdict();
+        }
+        boolean matched = normalizeText(run.stdout()).equals(normalizeText(tc.output()));
+        return matched ? Submission.STATUS_AC : Submission.STATUS_WA;
+    }
+
+    /**
+     * 生成用例判定对应的提示信息,供落库展示
+     * @param verdict 用例判定
+     * @param run 用例运行结果
+     * @return 提示信息
+     */
+    private String resolveMessage(String verdict, RunResult run) {
+        if (Submission.STATUS_TLE.equals(verdict)) {
+            return "运行时间超限";
+        }
+        if (Submission.STATUS_WA.equals(verdict)) {
+            return "输出与预期不符";
+        }
+        if (Submission.STATUS_SE.equals(verdict)) {
+            return "系统异常";
+        }
+        // RE:输出为空时退回标准输出,否则展示错误输出
+        return run.stderr().isEmpty() ? run.stdout() : run.stderr();
+    }
+
+    private static String normalizeText(String text) {
+        if (text == null) {
+            text = "";
+        }
+        String normalized = text.replace("\r\n", "\n").replace("\r", "\n");
+        List<String> lines = new ArrayList<>();
+        for (String line : normalized.lines().map(String::stripTrailing).toList()) {
+            lines.add(line);
+        }
+        while (!lines.isEmpty() && lines.get(lines.size() - 1).isEmpty()) {
+            lines.remove(lines.size() - 1);
+        }
+        return String.join("\n", lines);
+    }
+
+    private CodeResponse getCodeResponse(Long id, String status, String message) {
+        return new CodeResponse(id, status, 0L, 0L, Collections.emptyList(), message);
+    }
+
+    private Long getMaxTime(List<TestCaseResponse> results) {
+        if (results == null || results.isEmpty()) {
+            return 0L;
+        }
+        return results.stream().map(TestCaseResponse::usedTime).max(Long::compareTo).orElse(0L);
+    }
+
+    private Long getMinMemory(List<TestCaseResponse> results) {
+        if (results == null || results.isEmpty()) {
+            return 0L;
+        }
+        return results.stream().map(TestCaseResponse::usedMemory).min(Long::compareTo).orElse(0L);
+    }
+}

+ 37 - 0
src/main/java/space/anyi/codesandbox/docker/DockerClientFactory.java

@@ -0,0 +1,37 @@
+package space.anyi.codesandbox.docker;
+
+import com.github.dockerjava.api.DockerClient;
+import com.github.dockerjava.core.DefaultDockerClientConfig;
+import com.github.dockerjava.core.DockerClientConfig;
+import com.github.dockerjava.core.DockerClientImpl;
+import com.github.dockerjava.httpclient5.ApacheDockerHttpClient;
+import com.github.dockerjava.transport.DockerHttpClient;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import java.time.Duration;
+
+/**
+ * @fileName: DockerClientFactory
+ * @projectName: serve
+ * @package: space.anyi.codesandbox.docker
+ * @author: yangyi
+ * @date:14/10/2026 5:44 pm
+ * @description: Docker Client 配置类,注册 DockerClient Bean 供沙箱注入
+ */
+@Configuration
+public class DockerClientFactory {
+
+    @Bean
+    public DockerClient dockerClient() {
+        DockerClientConfig dockerClientConfig = DefaultDockerClientConfig.createDefaultConfigBuilder()
+                .build();
+        DockerHttpClient httpClient = new ApacheDockerHttpClient.Builder()
+                .dockerHost(dockerClientConfig.getDockerHost())
+                .maxConnections(100)
+                .connectionTimeout(Duration.ofSeconds(30))
+                .responseTimeout(Duration.ofSeconds(45))
+                .build();
+        return DockerClientImpl.getInstance(dockerClientConfig, httpClient);
+    }
+}

+ 10 - 5
src/main/java/space/anyi/serve/config/CodeSandBoxAutoConfiguration.java

@@ -1,19 +1,24 @@
 package space.anyi.serve.config;
 
+import com.github.dockerjava.api.DockerClient;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Import;
 import space.anyi.codesandbox.CodeSandBox;
-import space.anyi.codesandbox.JVMNativeCodeSandBox;
+import space.anyi.codesandbox.DockerCodeSandBox;
+import space.anyi.codesandbox.docker.DockerClientFactory;
 
 /**
  * 代码沙箱自动配置,注册沙箱实现Bean
- * 后续新增 Docker 沙箱时,可在此通过 @ConditionalOnProperty 切换实现
+ * 通过 @Import 引入 DockerClient 配置(该配置类不在 serve 包下,自动扫描无法覆盖)
+ * 后续新增沙箱实现时,可在此通过 @ConditionalOnProperty 切换
  */
 @Configuration
+@Import(DockerClientFactory.class)
 public class CodeSandBoxAutoConfiguration {
 
     @Bean
-    public CodeSandBox codeSandBox() {
-        return new JVMNativeCodeSandBox();
+    public CodeSandBox codeSandBox(DockerClient dockerClient) {
+        return new DockerCodeSandBox(dockerClient);
     }
-}
+}

+ 129 - 0
src/test/java/space/anyi/codesandbox/DockerCodeSandBoxTest.java

@@ -0,0 +1,129 @@
+package space.anyi.codesandbox;
+
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import space.anyi.codesandbox.docker.DockerClientFactory;
+import space.anyi.serve.entity.contest.Submission;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * @fileName: DockerCodeSandBoxTest
+ * @projectName: serve
+ * @package: space.anyi.codesandbox
+ * @author: yangyi
+ * @date:14/10/2026 6:00 pm
+ * @description: Docker 代码沙箱测试,需本机 Docker daemon 运行中,判题镜像缺失时自动拉取
+ */
+class DockerCodeSandBoxTest {
+
+    private static DockerCodeSandBox dockerCodeSandBox;
+
+    @BeforeAll
+    static void setUp() {
+        dockerCodeSandBox = new DockerCodeSandBox(new DockerClientFactory().dockerClient());
+    }
+
+    @Test
+    void testAcAndMultiLineOutput() {
+        String code = "public class Main {\n" +
+                "    public static void main(String[] args) {\n" +
+                "        java.util.Scanner in = new java.util.Scanner(System.in);\n" +
+                "        int a = in.nextInt();\n" +
+                "        int b = in.nextInt();\n" +
+                "        System.out.println(a + b);\n" +
+                "    }\n" +
+                "}";
+        List<TestCase> testCases = List.of(new TestCase(1L, "1 2", "3"), new TestCase(2L, "2 2", "4"));
+        CodeContext codeContext = new CodeContext(1L, code, "java", 2000L, 256 * 1024L, testCases);
+        CodeResponse codeResponse = dockerCodeSandBox.process(codeContext);
+        assertEquals(Submission.STATUS_AC, codeResponse.status());
+        assertEquals(2, codeResponse.testCaseResponses().size());
+    }
+
+    @Test
+    void testCompileError() {
+        String code = "public class Main {\n" +
+                "    public static void main(String[] args) {\n" +
+                "        int x = \"类型不匹配\";\n" +
+                "    }\n" +
+                "}";
+        CodeContext codeContext = new CodeContext(2L, code, "java", 2000L, 256 * 1024L,
+                List.of(new TestCase(1L, "", "")));
+        CodeResponse codeResponse = dockerCodeSandBox.process(codeContext);
+        assertEquals(Submission.STATUS_CE, codeResponse.status());
+    }
+
+    @Test
+    void testRuntimeError() {
+        String code = "public class Main {\n" +
+                "    public static void main(String[] args) {\n" +
+                "        int x = 1 / 0;\n" +
+                "        System.out.println(x);\n" +
+                "    }\n" +
+                "}";
+        CodeContext codeContext = new CodeContext(3L, code, "java", 2000L, 256 * 1024L,
+                List.of(new TestCase(1L, "", "")));
+        CodeResponse codeResponse = dockerCodeSandBox.process(codeContext);
+        assertEquals(Submission.STATUS_RE, codeResponse.status());
+    }
+
+    @Test
+    void testWrongAnswer() {
+        String code = "public class Main {\n" +
+                "    public static void main(String[] args) {\n" +
+                "        System.out.println(9);\n" +
+                "    }\n" +
+                "}";
+        CodeContext codeContext = new CodeContext(4L, code, "java", 2000L, 256 * 1024L,
+                List.of(new TestCase(1L, "", "3")));
+        CodeResponse codeResponse = dockerCodeSandBox.process(codeContext);
+        assertEquals(Submission.STATUS_WA, codeResponse.status());
+    }
+
+    @Test
+    void testTrailingWhitespaceIgnored() {
+        String code = "public class Main {\n" +
+                "    public static void main(String[] args) {\n" +
+                "        System.out.println(\"3  \\t\");\n" +
+                "    }\n" +
+                "}";
+        CodeContext codeContext = new CodeContext(5L, code, "java", 2000L, 256 * 1024L,
+                List.of(new TestCase(1L, "", "3")));
+        CodeResponse codeResponse = dockerCodeSandBox.process(codeContext);
+        assertEquals(Submission.STATUS_AC, codeResponse.status());
+    }
+
+    @Test
+    void testMultiByteUtf8Output() {
+        String code = "public class Main {\n" +
+                "    public static void main(String[] args) {\n" +
+                "        System.out.println(\"你好,世界!\");\n" +
+                "    }\n" +
+                "}";
+        CodeContext codeContext = new CodeContext(7L, code, "java", 2000L, 256 * 1024L,
+                List.of(new TestCase(1L, "", "你好,世界!")));
+        CodeResponse codeResponse = dockerCodeSandBox.process(codeContext);
+        assertEquals(Submission.STATUS_AC, codeResponse.status());
+    }
+
+    @Test
+    void testInfiniteLoopTle() {
+        String code = "public class Main {\n" +
+                "    public static void main(String[] args) {\n" +
+                "        while (true) {}\n" +
+                "    }\n" +
+                "}";
+        CodeContext codeContext = new CodeContext(6L, code, "java", 1000L, 256 * 1024L,
+                List.of(new TestCase(1L, "", "")));
+        long start = System.currentTimeMillis();
+        CodeResponse codeResponse = dockerCodeSandBox.process(codeContext);
+        long elapsed = System.currentTimeMillis() - start;
+        assertEquals(Submission.STATUS_TLE, codeResponse.status());
+        assertEquals(1, codeResponse.testCaseResponses().size());
+        // 超时应及时生效,允许容器创建/清理的开销
+        assertEquals(true, elapsed < 15_000, "TLE 超时未生效,实际耗时:" + elapsed + "ms");
+    }
+}