Enables code execution within a Docker sandbox
All checks were successful
Build and Push Docker Images / build (src/LiquidCode.Tester.Gateway/Dockerfile, git.nullptr.top/liquidcode/liquidcode-tester-gateway-roman, gateway) (push) Successful in 53s
Build and Push Docker Images / build (src/LiquidCode.Tester.Worker/Dockerfile, git.nullptr.top/liquidcode/liquidcode-tester-worker-roman, worker) (push) Successful in 3m51s

Adds a Docker-based execution sandbox to enhance the security and resource management of code execution.

This change introduces:
- New execution services for C#, C++, Java, Kotlin, and Python that utilize the sandbox.
- Configuration options for enabling/disabling the sandbox and specifying Docker images for different languages.
- Batch execution support for C++ to improve the efficiency of generating answer files.
- Docker CLI installation in the worker's Dockerfile.

The sandbox provides improved isolation and resource control during code execution, preventing potential security vulnerabilities and resource exhaustion.
This commit is contained in:
2025-11-05 01:18:12 +03:00
parent 8e6c2c222e
commit 147c95a48a
19 changed files with 1023 additions and 507 deletions

View File

@@ -40,6 +40,8 @@ RUN apt-get update && \
# Python
python3 \
python3-pip \
# Docker CLI for nested sandboxing
docker.io \
# Kotlin compiler
wget \
unzip \

View File

@@ -1,4 +1,5 @@
using LiquidCode.Tester.Worker.Services;
using LiquidCode.Tester.Worker.Services.Sandbox;
var builder = WebApplication.CreateBuilder(args);
@@ -15,6 +16,8 @@ builder.Services.AddSingleton<AnswerGenerationService>();
builder.Services.AddSingleton<CheckerService>();
builder.Services.AddSingleton<IPackageCacheService, PackageCacheService>();
builder.Services.AddSingleton<IPackageParserService, PackageParserService>();
builder.Services.Configure<SandboxOptions>(builder.Configuration.GetSection("Sandbox"));
builder.Services.AddSingleton<IExecutionSandbox, ExecutionSandbox>();
builder.Services.AddSingleton<IOutputCheckerService, OutputCheckerService>();
builder.Services.AddSingleton<ICallbackService, CallbackService>();

View File

@@ -71,12 +71,94 @@ public class AnswerGenerationService
return false;
}
_logger.LogInformation("Main solution compiled successfully");
_logger.LogInformation("Main solution compiled successfully to {Executable}", compilationResult.ExecutablePath);
// Generate answers for each test
int generatedCount = 0;
for (int i = 0; i < inputFilePaths.Count; i++)
var totalTests = inputFilePaths.Count;
if (totalTests == 0)
{
return false;
}
var problemTimeLimit = descriptor.TimeLimitMs > 0 ? descriptor.TimeLimitMs : 1000;
var answerTimeLimit = Math.Max(problemTimeLimit * 5, 10000);
var executionMemoryLimit = descriptor.MemoryLimitMb > 0 ? descriptor.MemoryLimitMb * 2 : 512;
var completedIndices = new HashSet<int>();
var generatedCount = 0;
if (executionService is CppExecutionService cppExecutionService && totalTests > 1)
{
var batchResult = await cppExecutionService.ExecuteBatchAsync(
compilationResult.ExecutablePath!,
workingDirectory,
inputFilePaths,
answerFilePaths,
answerTimeLimit,
executionMemoryLimit).ConfigureAwait(false);
if (batchResult is not null)
{
for (var i = 0; i < totalTests; i++)
{
if (File.Exists(answerFilePaths[i]) && new FileInfo(answerFilePaths[i]).Length > 0)
{
completedIndices.Add(i);
generatedCount++;
}
}
if (batchResult.Success && completedIndices.Count == totalTests)
{
_logger.LogInformation(
batchResult.UsedSandbox
? "Generated answers for {Count} tests via sandbox batch execution"
: "Generated answers for {Count} tests via local batch execution",
totalTests);
return true;
}
if (completedIndices.Count > 0 && completedIndices.Count < totalTests)
{
_logger.LogInformation(
"{Mode} produced {Generated}/{Total} answers; retrying remaining {Remaining} individually",
batchResult.UsedSandbox ? "Sandbox batch execution" : "Local batch execution",
completedIndices.Count,
totalTests,
totalTests - completedIndices.Count);
}
if (!batchResult.Success)
{
var error = string.IsNullOrWhiteSpace(batchResult.ErrorMessage)
? batchResult.StandardError
: batchResult.ErrorMessage;
var mode = batchResult.UsedSandbox ? "Sandbox batch execution" : "Local batch execution";
if (!string.IsNullOrWhiteSpace(error))
{
_logger.LogWarning("{Mode} exited with code {ExitCode}: {Message}", mode, batchResult.ExitCode, error.Trim());
}
else
{
_logger.LogWarning("{Mode} exited with code {ExitCode}", mode, batchResult.ExitCode);
}
}
if (completedIndices.Count == totalTests)
{
return generatedCount > 0;
}
}
}
for (int i = 0; i < totalTests; i++)
{
if (completedIndices.Contains(i))
{
continue;
}
var inputPath = inputFilePaths[i];
var answerPath = answerFilePaths[i];
@@ -87,14 +169,13 @@ public class AnswerGenerationService
}
_logger.LogDebug("Generating answer {Index}/{Total}: {AnswerPath}",
i + 1, inputFilePaths.Count, answerPath);
i + 1, totalTests, answerPath);
// Execute solution with input
var executionResult = await executionService.ExecuteAsync(
compilationResult.ExecutablePath!,
inputPath,
descriptor.TimeLimitMs * 2, // Give extra time for answer generation
descriptor.MemoryLimitMb * 2);
answerTimeLimit,
executionMemoryLimit).ConfigureAwait(false);
if (!executionResult.Success || executionResult.RuntimeError)
{
@@ -103,21 +184,20 @@ public class AnswerGenerationService
continue;
}
// Save output as answer file
var answerDir = Path.GetDirectoryName(answerPath);
if (!string.IsNullOrEmpty(answerDir) && !Directory.Exists(answerDir))
{
Directory.CreateDirectory(answerDir);
}
await File.WriteAllTextAsync(answerPath, executionResult.Output);
await File.WriteAllTextAsync(answerPath, executionResult.Output).ConfigureAwait(false);
generatedCount++;
_logger.LogDebug("Generated answer {Index}/{Total}", i + 1, inputFilePaths.Count);
_logger.LogDebug("Generated answer {Index}/{Total}", i + 1, totalTests);
}
_logger.LogInformation("Generated {Count} answer files out of {Total} tests",
generatedCount, inputFilePaths.Count);
generatedCount, totalTests);
return generatedCount > 0;
}

View File

@@ -1,108 +1,38 @@
using System.Diagnostics;
using System.IO;
using LiquidCode.Tester.Worker.Services.Sandbox;
namespace LiquidCode.Tester.Worker.Services;
public class CSharpExecutionService : IExecutionService
{
private readonly ILogger<CSharpExecutionService> _logger;
private readonly IExecutionSandbox _sandbox;
public CSharpExecutionService(ILogger<CSharpExecutionService> logger)
public CSharpExecutionService(IExecutionSandbox sandbox, ILogger<CSharpExecutionService> logger)
{
_sandbox = sandbox;
_logger = logger;
}
public async Task<ExecutionResult> ExecuteAsync(string executablePath, string inputFilePath, int timeLimitMs, int memoryLimitMb)
{
_logger.LogInformation("Executing C# executable {Executable} with input {Input}, time limit {TimeLimit}ms, memory limit {MemoryLimit}MB",
executablePath, inputFilePath, timeLimitMs, memoryLimitMb);
_logger.LogInformation("Executing C# executable {Executable} with input {Input}", executablePath, inputFilePath);
var result = new ExecutionResult();
var stopwatch = Stopwatch.StartNew();
var workingDirectory = Path.GetDirectoryName(executablePath) ?? Directory.GetCurrentDirectory();
var executableFileName = Path.GetFileName(executablePath);
try
var request = new ExecutionSandboxRequest
{
using var inputStream = File.OpenRead(inputFilePath);
Language = "csharp",
WorkingDirectory = workingDirectory,
Executable = $"./{executableFileName}",
TimeLimitMilliseconds = timeLimitMs,
MemoryLimitMegabytes = memoryLimitMb,
StandardInputFile = inputFilePath,
AllowNetwork = false
};
var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = executablePath,
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
}
};
process.Start();
await inputStream.CopyToAsync(process.StandardInput.BaseStream);
process.StandardInput.Close();
var outputTask = process.StandardOutput.ReadToEndAsync();
var errorTask = process.StandardError.ReadToEndAsync();
var completedInTime = await Task.Run(() => process.WaitForExit(timeLimitMs));
stopwatch.Stop();
result.ExecutionTimeMs = stopwatch.ElapsedMilliseconds;
if (!completedInTime)
{
try
{
process.Kill(entireProcessTree: true);
}
catch { }
result.TimeLimitExceeded = true;
result.ErrorMessage = "Time limit exceeded";
_logger.LogWarning("Execution exceeded time limit");
return result;
}
result.Output = await outputTask;
result.ErrorOutput = await errorTask;
result.ExitCode = process.ExitCode;
if (process.ExitCode != 0)
{
result.RuntimeError = true;
result.ErrorMessage = $"Runtime error (exit code {process.ExitCode})";
_logger.LogWarning("Runtime error with exit code {ExitCode}", process.ExitCode);
return result;
}
try
{
result.MemoryUsedMb = process.PeakWorkingSet64 / (1024 * 1024);
if (result.MemoryUsedMb > memoryLimitMb)
{
result.MemoryLimitExceeded = true;
result.ErrorMessage = "Memory limit exceeded";
_logger.LogWarning("Memory limit exceeded: {Used}MB > {Limit}MB", result.MemoryUsedMb, memoryLimitMb);
return result;
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not measure memory usage");
}
result.Success = true;
_logger.LogInformation("Execution completed successfully in {Time}ms", result.ExecutionTimeMs);
}
catch (Exception ex)
{
stopwatch.Stop();
result.ExecutionTimeMs = stopwatch.ElapsedMilliseconds;
result.RuntimeError = true;
result.ErrorMessage = $"Execution error: {ex.Message}";
_logger.LogError(ex, "Error during execution");
}
return result;
var sandboxResult = await _sandbox.RunAsync(request).ConfigureAwait(false);
return sandboxResult.ToExecutionResult(memoryLimitMb);
}
}

View File

@@ -1,114 +1,191 @@
using System.Diagnostics;
using System.IO;
using LiquidCode.Tester.Worker.Services.Sandbox;
namespace LiquidCode.Tester.Worker.Services;
public class CppExecutionService : IExecutionService
{
private readonly ILogger<CppExecutionService> _logger;
private readonly IExecutionSandbox _sandbox;
public CppExecutionService(ILogger<CppExecutionService> logger)
public CppExecutionService(IExecutionSandbox sandbox, ILogger<CppExecutionService> logger)
{
_sandbox = sandbox;
_logger = logger;
}
public async Task<ExecutionResult> ExecuteAsync(string executablePath, string inputFilePath, int timeLimitMs, int memoryLimitMb)
{
_logger.LogInformation("Executing {Executable} with input {Input}, time limit {TimeLimit}ms, memory limit {MemoryLimit}MB",
_logger.LogInformation("Executing {Executable} with input {Input}, limits {TimeLimit}ms/{MemoryLimit}MB",
executablePath, inputFilePath, timeLimitMs, memoryLimitMb);
var result = new ExecutionResult();
var stopwatch = Stopwatch.StartNew();
var workingDirectory = Path.GetDirectoryName(executablePath) ?? Directory.GetCurrentDirectory();
var executableFileName = Path.GetFileName(executablePath);
var request = new ExecutionSandboxRequest
{
Language = "cpp",
WorkingDirectory = workingDirectory,
Executable = $"./{executableFileName}",
TimeLimitMilliseconds = timeLimitMs,
MemoryLimitMegabytes = memoryLimitMb,
StandardInputFile = inputFilePath,
AllowNetwork = false
};
var sandboxResult = await _sandbox.RunAsync(request).ConfigureAwait(false);
return sandboxResult.ToExecutionResult(memoryLimitMb);
}
public async Task<ExecutionSandboxResult?> ExecuteBatchAsync(
string executablePath,
string packageRootDirectory,
IReadOnlyList<string> inputFilePaths,
IReadOnlyList<string> outputFilePaths,
int perTestTimeLimitMs,
int memoryLimitMb,
CancellationToken cancellationToken = default)
{
if (inputFilePaths.Count == 0 || inputFilePaths.Count != outputFilePaths.Count)
{
_logger.LogWarning("Invalid batch execution request: inputs={Inputs}, outputs={Outputs}", inputFilePaths.Count, outputFilePaths.Count);
return null;
}
var sandboxRoot = Path.GetFullPath(packageRootDirectory);
Directory.CreateDirectory(sandboxRoot);
var sandboxWorkDir = Path.Combine(sandboxRoot, ".lc-sandbox");
Directory.CreateDirectory(sandboxWorkDir);
var scriptPath = Path.Combine(sandboxWorkDir, "run-batch.sh");
var jobsPath = Path.Combine(sandboxWorkDir, "jobs.txt");
var executableRelative = NormalizeRelativePath(Path.GetRelativePath(sandboxRoot, executablePath));
var scriptRelative = NormalizeRelativePath(Path.GetRelativePath(sandboxRoot, scriptPath));
var jobsRelative = NormalizeRelativePath(Path.GetRelativePath(sandboxRoot, jobsPath));
if (executableRelative.StartsWith("../", StringComparison.Ordinal))
{
_logger.LogWarning("Executable path {ExecutablePath} is outside of package root {PackageRoot}. Batch execution cancelled.", executablePath, sandboxRoot);
return null;
}
var jobLines = new List<string>(inputFilePaths.Count);
for (var i = 0; i < inputFilePaths.Count; i++)
{
var inputRelative = NormalizeRelativePath(Path.GetRelativePath(sandboxRoot, inputFilePaths[i]));
var outputRelative = NormalizeRelativePath(Path.GetRelativePath(sandboxRoot, outputFilePaths[i]));
if (inputRelative.StartsWith("../", StringComparison.Ordinal) || outputRelative.StartsWith("../", StringComparison.Ordinal))
{
_logger.LogWarning("Input or output path is outside of package root. Falling back to sequential execution.");
return null;
}
jobLines.Add($"{inputRelative}|{outputRelative}");
}
await File.WriteAllLinesAsync(jobsPath, jobLines, cancellationToken).ConfigureAwait(false);
var scriptContent = """
#!/bin/sh
set -eu
EXEC="$1"
JOBS_FILE="$2"
rc=0
IFS='|'
while IFS='|' read -r input_path output_path || [ -n "$input_path" ]; do
if [ -z "$input_path" ]; then
continue
fi
output_dir=$(dirname "$output_path")
mkdir -p "$output_dir"
if ! "$EXEC" < "$input_path" > "$output_path"; then
status=$?
echo "FAILED $input_path (exit $status)" >&2
if [ "$rc" -eq 0 ]; then
rc=$status
fi
fi
done < "$JOBS_FILE"
exit "$rc"
""";
await File.WriteAllTextAsync(scriptPath, scriptContent, cancellationToken).ConfigureAwait(false);
var request = new ExecutionSandboxRequest
{
Language = "cpp",
WorkingDirectory = sandboxRoot,
Executable = "/bin/sh",
TimeLimitMilliseconds = CalculateOverallTimeLimit(perTestTimeLimitMs, inputFilePaths.Count),
MemoryLimitMegabytes = Math.Max(memoryLimitMb, 1),
AllowNetwork = false
};
request.Arguments.Add(EnsureRelativeForShell(scriptRelative));
request.Arguments.Add(EnsureRelativeForShell(executableRelative));
request.Arguments.Add(EnsureRelativeForShell(jobsRelative));
_logger.LogInformation("Executing batch of {Count} tests inside sandbox (per-test limit {Limit} ms)", inputFilePaths.Count, perTestTimeLimitMs);
try
{
using var inputStream = File.OpenRead(inputFilePath);
return await _sandbox.RunAsync(request, cancellationToken).ConfigureAwait(false);
}
finally
{
TryDelete(scriptPath);
TryDelete(jobsPath);
}
}
var process = new Process
private static string NormalizeRelativePath(string path) => path.Replace('\\', '/');
private static string EnsureRelativeForShell(string path)
{
if (string.IsNullOrWhiteSpace(path))
{
return path;
}
if (path.StartsWith("./", StringComparison.Ordinal) || path.StartsWith("/", StringComparison.Ordinal))
{
return path;
}
return $"./{path}";
}
private static int CalculateOverallTimeLimit(int perTestTimeLimitMs, int testCount)
{
var perTest = Math.Max(perTestTimeLimitMs, 1000);
var count = Math.Max(testCount, 1);
var buffer = Math.Max(perTest / 2, 1000);
var total = (long)perTest * count + buffer;
return total >= int.MaxValue ? int.MaxValue : (int)total;
}
private void TryDelete(string path)
{
try
{
if (File.Exists(path))
{
StartInfo = new ProcessStartInfo
{
FileName = executablePath,
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
}
};
process.Start();
// Copy input to stdin
await inputStream.CopyToAsync(process.StandardInput.BaseStream);
process.StandardInput.Close();
// Read output
var outputTask = process.StandardOutput.ReadToEndAsync();
var errorTask = process.StandardError.ReadToEndAsync();
// Wait for completion with timeout
var completedInTime = await Task.Run(() => process.WaitForExit(timeLimitMs));
stopwatch.Stop();
result.ExecutionTimeMs = stopwatch.ElapsedMilliseconds;
if (!completedInTime)
{
// Time limit exceeded
try
{
process.Kill(entireProcessTree: true);
}
catch { }
result.TimeLimitExceeded = true;
result.ErrorMessage = "Time limit exceeded";
_logger.LogWarning("Execution exceeded time limit");
return result;
File.Delete(path);
}
result.Output = await outputTask;
result.ErrorOutput = await errorTask;
result.ExitCode = process.ExitCode;
// Check for runtime error
if (process.ExitCode != 0)
{
result.RuntimeError = true;
result.ErrorMessage = $"Runtime error (exit code {process.ExitCode})";
_logger.LogWarning("Runtime error with exit code {ExitCode}", process.ExitCode);
return result;
}
// Estimate memory usage (basic approach - in real scenario we'd use cgroups)
try
{
result.MemoryUsedMb = process.PeakWorkingSet64 / (1024 * 1024);
if (result.MemoryUsedMb > memoryLimitMb)
{
result.MemoryLimitExceeded = true;
result.ErrorMessage = "Memory limit exceeded";
_logger.LogWarning("Memory limit exceeded: {Used}MB > {Limit}MB", result.MemoryUsedMb, memoryLimitMb);
return result;
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not measure memory usage");
}
result.Success = true;
_logger.LogInformation("Execution completed successfully in {Time}ms", result.ExecutionTimeMs);
}
catch (Exception ex)
{
stopwatch.Stop();
result.ExecutionTimeMs = stopwatch.ElapsedMilliseconds;
result.RuntimeError = true;
result.ErrorMessage = $"Execution error: {ex.Message}";
_logger.LogError(ex, "Error during execution");
_logger.LogDebug(ex, "Failed to delete temporary sandbox file {Path}", path);
}
return result;
}
}

View File

@@ -1,111 +1,37 @@
using System.Diagnostics;
using System.IO;
using LiquidCode.Tester.Worker.Services.Sandbox;
namespace LiquidCode.Tester.Worker.Services;
public class JavaExecutionService : IExecutionService
{
private readonly ILogger<JavaExecutionService> _logger;
private readonly IExecutionSandbox _sandbox;
public JavaExecutionService(ILogger<JavaExecutionService> logger)
public JavaExecutionService(IExecutionSandbox sandbox, ILogger<JavaExecutionService> logger)
{
_sandbox = sandbox;
_logger = logger;
}
public async Task<ExecutionResult> ExecuteAsync(string executablePath, string inputFilePath, int timeLimitMs, int memoryLimitMb)
{
var workingDirectory = Path.GetDirectoryName(executablePath)!;
_logger.LogInformation("Executing Java class in {WorkingDirectory} with input {Input}, time limit {TimeLimit}ms, memory limit {MemoryLimit}MB",
workingDirectory, inputFilePath, timeLimitMs, memoryLimitMb);
var workingDirectory = Path.GetDirectoryName(executablePath) ?? Directory.GetCurrentDirectory();
_logger.LogInformation("Executing Java class from {WorkingDirectory} with input {Input}", workingDirectory, inputFilePath);
var result = new ExecutionResult();
var stopwatch = Stopwatch.StartNew();
try
var request = new ExecutionSandboxRequest
{
using var inputStream = File.OpenRead(inputFilePath);
Language = "java",
WorkingDirectory = workingDirectory,
Executable = "java",
Arguments = { "-cp", ".", "Solution" },
TimeLimitMilliseconds = timeLimitMs,
MemoryLimitMegabytes = memoryLimitMb,
StandardInputFile = inputFilePath,
AllowNetwork = false
};
var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = "java",
Arguments = "-cp . Solution",
WorkingDirectory = workingDirectory,
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
}
};
process.Start();
await inputStream.CopyToAsync(process.StandardInput.BaseStream);
process.StandardInput.Close();
var outputTask = process.StandardOutput.ReadToEndAsync();
var errorTask = process.StandardError.ReadToEndAsync();
var completedInTime = await Task.Run(() => process.WaitForExit(timeLimitMs));
stopwatch.Stop();
result.ExecutionTimeMs = stopwatch.ElapsedMilliseconds;
if (!completedInTime)
{
try
{
process.Kill(entireProcessTree: true);
}
catch { }
result.TimeLimitExceeded = true;
result.ErrorMessage = "Time limit exceeded";
_logger.LogWarning("Execution exceeded time limit");
return result;
}
result.Output = await outputTask;
result.ErrorOutput = await errorTask;
result.ExitCode = process.ExitCode;
if (process.ExitCode != 0)
{
result.RuntimeError = true;
result.ErrorMessage = $"Runtime error (exit code {process.ExitCode})";
_logger.LogWarning("Runtime error with exit code {ExitCode}", process.ExitCode);
return result;
}
try
{
result.MemoryUsedMb = process.PeakWorkingSet64 / (1024 * 1024);
if (result.MemoryUsedMb > memoryLimitMb)
{
result.MemoryLimitExceeded = true;
result.ErrorMessage = "Memory limit exceeded";
_logger.LogWarning("Memory limit exceeded: {Used}MB > {Limit}MB", result.MemoryUsedMb, memoryLimitMb);
return result;
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not measure memory usage");
}
result.Success = true;
_logger.LogInformation("Execution completed successfully in {Time}ms", result.ExecutionTimeMs);
}
catch (Exception ex)
{
stopwatch.Stop();
result.ExecutionTimeMs = stopwatch.ElapsedMilliseconds;
result.RuntimeError = true;
result.ErrorMessage = $"Execution error: {ex.Message}";
_logger.LogError(ex, "Error during execution");
}
return result;
var sandboxResult = await _sandbox.RunAsync(request).ConfigureAwait(false);
return sandboxResult.ToExecutionResult(memoryLimitMb);
}
}

View File

@@ -1,110 +1,39 @@
using System.Diagnostics;
using System.IO;
using LiquidCode.Tester.Worker.Services.Sandbox;
namespace LiquidCode.Tester.Worker.Services;
public class KotlinExecutionService : IExecutionService
{
private readonly ILogger<KotlinExecutionService> _logger;
private readonly IExecutionSandbox _sandbox;
public KotlinExecutionService(ILogger<KotlinExecutionService> logger)
public KotlinExecutionService(IExecutionSandbox sandbox, ILogger<KotlinExecutionService> logger)
{
_sandbox = sandbox;
_logger = logger;
}
public async Task<ExecutionResult> ExecuteAsync(string executablePath, string inputFilePath, int timeLimitMs, int memoryLimitMb)
{
_logger.LogInformation("Executing Kotlin JAR {Executable} with input {Input}, time limit {TimeLimit}ms, memory limit {MemoryLimit}MB",
executablePath, inputFilePath, timeLimitMs, memoryLimitMb);
_logger.LogInformation("Executing Kotlin jar {Executable} with input {Input}", executablePath, inputFilePath);
var result = new ExecutionResult();
var stopwatch = Stopwatch.StartNew();
var workingDirectory = Path.GetDirectoryName(executablePath) ?? Directory.GetCurrentDirectory();
var executableFileName = Path.GetFileName(executablePath);
try
var request = new ExecutionSandboxRequest
{
using var inputStream = File.OpenRead(inputFilePath);
Language = "kotlin",
WorkingDirectory = workingDirectory,
Executable = "java",
Arguments = { "-jar", executableFileName },
TimeLimitMilliseconds = timeLimitMs,
MemoryLimitMegabytes = memoryLimitMb,
StandardInputFile = inputFilePath,
AllowNetwork = false
};
var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = "java",
Arguments = $"-jar \"{executablePath}\"",
WorkingDirectory = Path.GetDirectoryName(executablePath),
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
}
};
process.Start();
await inputStream.CopyToAsync(process.StandardInput.BaseStream);
process.StandardInput.Close();
var outputTask = process.StandardOutput.ReadToEndAsync();
var errorTask = process.StandardError.ReadToEndAsync();
var completedInTime = await Task.Run(() => process.WaitForExit(timeLimitMs));
stopwatch.Stop();
result.ExecutionTimeMs = stopwatch.ElapsedMilliseconds;
if (!completedInTime)
{
try
{
process.Kill(entireProcessTree: true);
}
catch { }
result.TimeLimitExceeded = true;
result.ErrorMessage = "Time limit exceeded";
_logger.LogWarning("Execution exceeded time limit");
return result;
}
result.Output = await outputTask;
result.ErrorOutput = await errorTask;
result.ExitCode = process.ExitCode;
if (process.ExitCode != 0)
{
result.RuntimeError = true;
result.ErrorMessage = $"Runtime error (exit code {process.ExitCode})";
_logger.LogWarning("Runtime error with exit code {ExitCode}", process.ExitCode);
return result;
}
try
{
result.MemoryUsedMb = process.PeakWorkingSet64 / (1024 * 1024);
if (result.MemoryUsedMb > memoryLimitMb)
{
result.MemoryLimitExceeded = true;
result.ErrorMessage = "Memory limit exceeded";
_logger.LogWarning("Memory limit exceeded: {Used}MB > {Limit}MB", result.MemoryUsedMb, memoryLimitMb);
return result;
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not measure memory usage");
}
result.Success = true;
_logger.LogInformation("Execution completed successfully in {Time}ms", result.ExecutionTimeMs);
}
catch (Exception ex)
{
stopwatch.Stop();
result.ExecutionTimeMs = stopwatch.ElapsedMilliseconds;
result.RuntimeError = true;
result.ErrorMessage = $"Execution error: {ex.Message}";
_logger.LogError(ex, "Error during execution");
}
return result;
var sandboxResult = await _sandbox.RunAsync(request).ConfigureAwait(false);
return sandboxResult.ToExecutionResult(memoryLimitMb);
}
}

View File

@@ -89,7 +89,7 @@ public class PackageParserService : IPackageParserService
package.CheckerPath = await CompileCheckerForPackageAsync(descriptor, packageRoot, buildDirectory)
?? await FindAndCompileCheckerAsync(packageRoot);
var validatorPath = await CompileValidatorAsync(descriptor, packageRoot, buildDirectory, compiledExecutables);
var validatorPath = await CompileValidatorAsync(descriptor, packageRoot, buildDirectory, compiledExecutables);
await GenerateAndValidateTestsAsync(descriptor, packageRoot, compiledExecutables, validatorPath);
@@ -103,41 +103,53 @@ public class PackageParserService : IPackageParserService
return await ParseLegacyPackage(packageRoot, extractionRoot);
}
var inputs = new List<(int index, string inputPath)>();
var answers = new Dictionary<int, string>();
var testDefinitionByIndex = descriptor.Tests
.GroupBy(t => t.Index)
.ToDictionary(g => g.Key, g => g.First());
var testDescriptors = new List<TestFileDescriptor>(testIndices.Count);
foreach (var testIndex in testIndices)
{
var inputRelative = FormatPolygonPattern(descriptor.InputPathPattern, testIndex);
var inputFullPath = Path.Combine(packageRoot, NormalizeRelativePath(inputRelative));
if (!File.Exists(inputFullPath))
{
_logger.LogWarning("Input file not found for test {Index}: {RelativePath}", testIndex, inputRelative);
continue;
}
var answerRelative = FormatPolygonPattern(descriptor.AnswerPathPattern, testIndex);
var answerFullPath = Path.Combine(packageRoot, NormalizeRelativePath(answerRelative));
inputs.Add((testIndex, inputFullPath));
answers[testIndex] = answerFullPath;
testDescriptors.Add(new TestFileDescriptor(
testIndex,
inputRelative,
inputFullPath,
answerRelative,
answerFullPath));
}
var missingInputIndices = new HashSet<int>();
var missingAnswerInputs = new List<string>();
var missingAnswerPaths = new List<string>();
foreach (var (index, inputPath) in inputs)
foreach (var testDescriptor in testDescriptors)
{
if (!answers.TryGetValue(index, out var answerPath))
if (!File.Exists(testDescriptor.InputFullPath))
{
var method = testDefinitionByIndex.TryGetValue(testDescriptor.Index, out var definition)
? definition.Method.ToString().ToLowerInvariant()
: "unknown";
_logger.LogWarning(
"Input file not found for test {Index} ({Method}): {RelativePath}",
testDescriptor.Index,
method,
testDescriptor.InputRelativePath);
missingInputIndices.Add(testDescriptor.Index);
continue;
}
if (!File.Exists(answerPath))
if (!File.Exists(testDescriptor.AnswerFullPath))
{
missingAnswerInputs.Add(inputPath);
missingAnswerPaths.Add(answerPath);
missingAnswerInputs.Add(testDescriptor.InputFullPath);
missingAnswerPaths.Add(testDescriptor.AnswerFullPath);
}
}
@@ -161,24 +173,35 @@ public class PackageParserService : IPackageParserService
}
}
foreach (var (index, inputPath) in inputs.OrderBy(item => item.index))
foreach (var testDescriptor in testDescriptors.OrderBy(t => t.Index))
{
if (!answers.TryGetValue(index, out var answerPath))
if (!File.Exists(testDescriptor.InputFullPath))
{
if (!missingInputIndices.Contains(testDescriptor.Index))
{
_logger.LogWarning(
"Input file not found for test {Index}: {RelativePath}",
testDescriptor.Index,
testDescriptor.InputRelativePath);
}
continue;
}
if (!File.Exists(answerPath))
if (!File.Exists(testDescriptor.AnswerFullPath))
{
_logger.LogWarning("Answer file not found for test {Index}: {AnswerPath}", index, answerPath);
_logger.LogWarning(
"Answer file not found for test {Index}: {RelativePath}",
testDescriptor.Index,
testDescriptor.AnswerRelativePath);
continue;
}
package.TestCases.Add(new TestCase
{
Number = index,
InputFilePath = inputPath,
OutputFilePath = answerPath,
Number = testDescriptor.Index,
InputFilePath = testDescriptor.InputFullPath,
OutputFilePath = testDescriptor.AnswerFullPath,
TimeLimit = descriptor.TimeLimitMs,
MemoryLimit = descriptor.MemoryLimitMb
});
@@ -787,4 +810,11 @@ public class PackageParserService : IPackageParserService
_logger.LogWarning("No checker found in package");
return null;
}
private sealed record TestFileDescriptor(
int Index,
string InputRelativePath,
string InputFullPath,
string AnswerRelativePath,
string AnswerFullPath);
}

View File

@@ -1,4 +1,5 @@
using System.Diagnostics;
using System.IO;
using LiquidCode.Tester.Worker.Services.Sandbox;
namespace LiquidCode.Tester.Worker.Services;
@@ -6,109 +7,36 @@ public class PythonExecutionService : IExecutionService
{
private readonly ILogger<PythonExecutionService> _logger;
private readonly IConfiguration _configuration;
private readonly IExecutionSandbox _sandbox;
public PythonExecutionService(ILogger<PythonExecutionService> logger, IConfiguration configuration)
public PythonExecutionService(ILogger<PythonExecutionService> logger, IConfiguration configuration, IExecutionSandbox sandbox)
{
_logger = logger;
_configuration = configuration;
_sandbox = sandbox;
}
public async Task<ExecutionResult> ExecuteAsync(string executablePath, string inputFilePath, int timeLimitMs, int memoryLimitMb)
{
_logger.LogInformation("Executing Python script {Executable} with input {Input}, time limit {TimeLimit}ms, memory limit {MemoryLimit}MB",
executablePath, inputFilePath, timeLimitMs, memoryLimitMb);
_logger.LogInformation("Executing Python script {Executable} with input {Input}", executablePath, inputFilePath);
var result = new ExecutionResult();
var stopwatch = Stopwatch.StartNew();
var pythonExecutable = _configuration["Python:Executable"] ?? "python3";
var workingDirectory = Path.GetDirectoryName(executablePath) ?? Directory.GetCurrentDirectory();
var scriptFileName = Path.GetFileName(executablePath);
try
var request = new ExecutionSandboxRequest
{
using var inputStream = File.OpenRead(inputFilePath);
Language = "python",
WorkingDirectory = workingDirectory,
Executable = pythonExecutable,
Arguments = { scriptFileName },
TimeLimitMilliseconds = timeLimitMs,
MemoryLimitMegabytes = memoryLimitMb,
StandardInputFile = inputFilePath,
AllowNetwork = false
};
var pythonExecutable = _configuration["Python:Executable"] ?? "python3";
var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = pythonExecutable,
Arguments = $"\"{executablePath}\"",
WorkingDirectory = Path.GetDirectoryName(executablePath),
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
}
};
process.Start();
await inputStream.CopyToAsync(process.StandardInput.BaseStream);
process.StandardInput.Close();
var outputTask = process.StandardOutput.ReadToEndAsync();
var errorTask = process.StandardError.ReadToEndAsync();
var completedInTime = await Task.Run(() => process.WaitForExit(timeLimitMs));
stopwatch.Stop();
result.ExecutionTimeMs = stopwatch.ElapsedMilliseconds;
if (!completedInTime)
{
try
{
process.Kill(entireProcessTree: true);
}
catch { }
result.TimeLimitExceeded = true;
result.ErrorMessage = "Time limit exceeded";
_logger.LogWarning("Execution exceeded time limit");
return result;
}
result.Output = await outputTask;
result.ErrorOutput = await errorTask;
result.ExitCode = process.ExitCode;
if (process.ExitCode != 0)
{
result.RuntimeError = true;
result.ErrorMessage = $"Runtime error (exit code {process.ExitCode})";
_logger.LogWarning("Runtime error with exit code {ExitCode}", process.ExitCode);
return result;
}
try
{
result.MemoryUsedMb = process.PeakWorkingSet64 / (1024 * 1024);
if (result.MemoryUsedMb > memoryLimitMb)
{
result.MemoryLimitExceeded = true;
result.ErrorMessage = "Memory limit exceeded";
_logger.LogWarning("Memory limit exceeded: {Used}MB > {Limit}MB", result.MemoryUsedMb, memoryLimitMb);
return result;
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not measure memory usage");
}
result.Success = true;
_logger.LogInformation("Execution completed successfully in {Time}ms", result.ExecutionTimeMs);
}
catch (Exception ex)
{
stopwatch.Stop();
result.ExecutionTimeMs = stopwatch.ElapsedMilliseconds;
result.RuntimeError = true;
result.ErrorMessage = $"Execution error: {ex.Message}";
_logger.LogError(ex, "Error during execution");
}
return result;
var sandboxResult = await _sandbox.RunAsync(request).ConfigureAwait(false);
return sandboxResult.ToExecutionResult(memoryLimitMb);
}
}

View File

@@ -0,0 +1,43 @@
namespace LiquidCode.Tester.Worker.Services.Sandbox;
public static class ExecutionResultMapper
{
public static ExecutionResult ToExecutionResult(this ExecutionSandboxResult sandboxResult, int memoryLimitMb)
{
var executionResult = new ExecutionResult
{
Success = sandboxResult.Success,
Output = sandboxResult.StandardOutput,
ErrorOutput = sandboxResult.StandardError,
ExitCode = sandboxResult.ExitCode,
ExecutionTimeMs = sandboxResult.ExecutionTimeMilliseconds,
MemoryUsedMb = sandboxResult.MemoryUsageBytes.HasValue ? sandboxResult.MemoryUsageBytes.Value / (1024 * 1024) : 0,
TimeLimitExceeded = sandboxResult.TimedOut,
MemoryLimitExceeded = sandboxResult.MemoryLimitExceeded,
ErrorMessage = sandboxResult.ErrorMessage ?? string.Empty,
RuntimeError = !sandboxResult.Success && !sandboxResult.TimedOut && !sandboxResult.MemoryLimitExceeded
};
if (sandboxResult.MemoryUsageBytes.HasValue)
{
executionResult.MemoryUsedMb = sandboxResult.MemoryUsageBytes.Value / (1024 * 1024);
}
if (sandboxResult.MemoryLimitExceeded && string.IsNullOrEmpty(executionResult.ErrorMessage))
{
executionResult.ErrorMessage = $"Memory limit exceeded ({memoryLimitMb} MB)";
}
if (sandboxResult.TimedOut && string.IsNullOrEmpty(executionResult.ErrorMessage))
{
executionResult.ErrorMessage = "Time limit exceeded";
}
if (!sandboxResult.Success && string.IsNullOrEmpty(executionResult.ErrorMessage))
{
executionResult.ErrorMessage = "Runtime error";
}
return executionResult;
}
}

View File

@@ -0,0 +1,435 @@
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace LiquidCode.Tester.Worker.Services.Sandbox;
public class ExecutionSandbox : IExecutionSandbox
{
private readonly SandboxOptions _options;
private readonly ILogger<ExecutionSandbox> _logger;
public ExecutionSandbox(IOptions<SandboxOptions> options, ILogger<ExecutionSandbox> logger)
{
_options = options.Value;
_logger = logger;
}
public async Task<ExecutionSandboxResult> RunAsync(ExecutionSandboxRequest request, CancellationToken cancellationToken = default)
{
if (!_options.Enabled)
{
return await RunLocallyAsync(request, cancellationToken).ConfigureAwait(false);
}
if (!_options.Languages.TryGetValue(request.Language, out var languageOptions) || string.IsNullOrWhiteSpace(languageOptions.ExecutionImage))
{
_logger.LogWarning("Sandbox enabled but no execution image configured for language {Language}. Falling back to local execution.", request.Language);
return await RunLocallyAsync(request, cancellationToken).ConfigureAwait(false);
}
try
{
return await RunInDockerAsync(request, languageOptions, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to execute inside Docker sandbox for language {Language}. Falling back to local execution.", request.Language);
return await RunLocallyAsync(request, cancellationToken).ConfigureAwait(false);
}
}
private async Task<ExecutionSandboxResult> RunLocallyAsync(ExecutionSandboxRequest request, CancellationToken cancellationToken)
{
var stopwatch = Stopwatch.StartNew();
var workingDirectory = string.IsNullOrWhiteSpace(request.WorkingDirectory)
? Directory.GetCurrentDirectory()
: request.WorkingDirectory;
var resolvedExecutablePath = Path.IsPathRooted(request.Executable)
? request.Executable
: Path.GetFullPath(Path.Combine(workingDirectory, request.Executable));
if (!Directory.Exists(workingDirectory))
{
_logger.LogWarning("Working directory not found for execution: {WorkingDirectory}", workingDirectory);
}
if (!File.Exists(resolvedExecutablePath))
{
_logger.LogWarning(
"Executable not found at resolved path {ResolvedPath} (WorkingDirectory: {WorkingDirectory}, Executable: {Executable})",
resolvedExecutablePath,
workingDirectory,
request.Executable);
}
var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = resolvedExecutablePath,
WorkingDirectory = workingDirectory,
RedirectStandardInput = request.StandardInputFile is not null,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
},
EnableRaisingEvents = true
};
foreach (var argument in request.Arguments)
{
process.StartInfo.ArgumentList.Add(argument);
}
try
{
process.Start();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to start local execution process");
return new ExecutionSandboxResult
{
Success = false,
ExitCode = -1,
ErrorMessage = ex.Message,
StandardError = ex.ToString(),
ExecutionTimeMilliseconds = stopwatch.ElapsedMilliseconds
};
}
var stdoutTask = process.StandardOutput.ReadToEndAsync();
var stderrTask = process.StandardError.ReadToEndAsync();
Task? stdinTask = null;
if (request.StandardInputFile is not null)
{
stdinTask = Task.Run(async () =>
{
try
{
await using var inputStream = File.OpenRead(request.StandardInputFile);
await inputStream.CopyToAsync(process.StandardInput.BaseStream, cancellationToken).ConfigureAwait(false);
await process.StandardInput.FlushAsync().ConfigureAwait(false);
process.StandardInput.Close();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to pipe input file {InputFile} to process", request.StandardInputFile);
}
}, cancellationToken);
}
var timeout = TimeSpan.FromMilliseconds(request.TimeLimitMilliseconds);
var waitForExitTask = process.WaitForExitAsync(cancellationToken);
var timeoutTask = Task.Delay(timeout);
var completedTask = await Task.WhenAny(waitForExitTask, timeoutTask).ConfigureAwait(false);
if (completedTask == timeoutTask)
{
TryKillProcessTree(process);
stopwatch.Stop();
var stdout = await stdoutTask.ConfigureAwait(false);
var stderr = await stderrTask.ConfigureAwait(false);
return new ExecutionSandboxResult
{
Success = false,
TimedOut = true,
StandardOutput = stdout,
StandardError = stderr,
ExecutionTimeMilliseconds = stopwatch.ElapsedMilliseconds,
ErrorMessage = "Time limit exceeded"
};
}
if (stdinTask is not null)
{
await stdinTask.ConfigureAwait(false);
}
await waitForExitTask.ConfigureAwait(false);
stopwatch.Stop();
var output = await stdoutTask.ConfigureAwait(false);
var errorOutput = await stderrTask.ConfigureAwait(false);
var exitCode = process.ExitCode;
var peakMemoryBytes = GetPeakMemory(process);
var memoryLimitBytes = request.MemoryLimitMegabytes > 0 ? request.MemoryLimitMegabytes * 1024L * 1024L : (long?)null;
var memoryExceeded = memoryLimitBytes.HasValue && peakMemoryBytes.HasValue && peakMemoryBytes.Value > memoryLimitBytes.Value;
return new ExecutionSandboxResult
{
Success = exitCode == 0 && !memoryExceeded,
ExitCode = exitCode,
StandardOutput = output,
StandardError = errorOutput,
MemoryUsageBytes = peakMemoryBytes,
MemoryLimitExceeded = memoryExceeded,
ExecutionTimeMilliseconds = stopwatch.ElapsedMilliseconds,
ErrorMessage = exitCode == 0 ? null : $"Runtime error (exit code {exitCode})"
};
}
private static void TryKillProcessTree(Process process)
{
try
{
process.Kill(entireProcessTree: true);
}
catch
{
}
}
private static long? GetPeakMemory(Process process)
{
try
{
return process.PeakWorkingSet64;
}
catch
{
return null;
}
}
private async Task<ExecutionSandboxResult> RunInDockerAsync(ExecutionSandboxRequest request, SandboxLanguageOptions languageOptions, CancellationToken cancellationToken)
{
var timeout = TimeSpan.FromMilliseconds(request.TimeLimitMilliseconds);
var memoryLimit = languageOptions.MemoryLimitMb ?? _options.DefaultMemoryLimitMb;
var cpuLimit = languageOptions.CpuLimit ?? _options.DefaultCpuLimit;
var pidsLimit = languageOptions.PidsLimit ?? _options.DefaultPidsLimit;
var containerName = $"lc-sbx-{Guid.NewGuid():N}";
var workingDirectory = Path.GetFullPath(request.WorkingDirectory);
if (!await EnsureDockerImageAvailableAsync(languageOptions.ExecutionImage!, cancellationToken).ConfigureAwait(false))
{
_logger.LogWarning("Docker image {Image} is not available locally. Falling back to local execution.", languageOptions.ExecutionImage);
return await RunLocallyAsync(request, cancellationToken).ConfigureAwait(false);
}
var processStartInfo = new ProcessStartInfo
{
FileName = _options.DockerExecutable,
RedirectStandardInput = request.StandardInputFile is not null,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false
};
processStartInfo.ArgumentList.Add("run");
processStartInfo.ArgumentList.Add("--rm");
processStartInfo.ArgumentList.Add("--name");
processStartInfo.ArgumentList.Add(containerName);
if (!request.AllowNetwork)
{
processStartInfo.ArgumentList.Add("--network");
processStartInfo.ArgumentList.Add("none");
}
processStartInfo.ArgumentList.Add("--cpus");
processStartInfo.ArgumentList.Add(cpuLimit.ToString(CultureInfo.InvariantCulture));
processStartInfo.ArgumentList.Add($"--memory={memoryLimit}m");
processStartInfo.ArgumentList.Add("--pids-limit");
processStartInfo.ArgumentList.Add(pidsLimit.ToString(CultureInfo.InvariantCulture));
processStartInfo.ArgumentList.Add("--security-opt");
processStartInfo.ArgumentList.Add("no-new-privileges");
processStartInfo.ArgumentList.Add("--cap-drop=ALL");
processStartInfo.ArgumentList.Add("--read-only");
processStartInfo.ArgumentList.Add("--tmpfs");
processStartInfo.ArgumentList.Add("/tmp:rw,noexec,nosuid,size=64m");
processStartInfo.ArgumentList.Add("-v");
processStartInfo.ArgumentList.Add($"{workingDirectory}:/workspace:rw");
processStartInfo.ArgumentList.Add("-w");
processStartInfo.ArgumentList.Add("/workspace");
processStartInfo.ArgumentList.Add(languageOptions.ExecutionImage!);
processStartInfo.ArgumentList.Add(request.Executable);
foreach (var argument in request.Arguments)
{
processStartInfo.ArgumentList.Add(argument);
}
var process = new Process { StartInfo = processStartInfo, EnableRaisingEvents = true };
var stopwatch = Stopwatch.StartNew();
try
{
process.Start();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to start docker process. Falling back to local execution.");
return await RunLocallyAsync(request, cancellationToken).ConfigureAwait(false);
}
Task? stdinTask = null;
if (request.StandardInputFile is not null)
{
stdinTask = Task.Run(async () =>
{
try
{
await using var inputStream = File.OpenRead(request.StandardInputFile);
await inputStream.CopyToAsync(process.StandardInput.BaseStream, cancellationToken).ConfigureAwait(false);
await process.StandardInput.FlushAsync().ConfigureAwait(false);
process.StandardInput.Close();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to stream stdin to docker sandbox");
}
}, cancellationToken);
}
var stdoutTask = process.StandardOutput.ReadToEndAsync();
var stderrTask = process.StandardError.ReadToEndAsync();
var waitForExitTask = process.WaitForExitAsync(cancellationToken);
var timeoutTask = Task.Delay(timeout);
var completedTask = await Task.WhenAny(waitForExitTask, timeoutTask).ConfigureAwait(false);
if (completedTask == timeoutTask)
{
await KillContainerAsync(containerName).ConfigureAwait(false);
stopwatch.Stop();
var stdout = await stdoutTask.ConfigureAwait(false);
var stderr = await stderrTask.ConfigureAwait(false);
return new ExecutionSandboxResult
{
Success = false,
TimedOut = true,
StandardOutput = stdout,
StandardError = stderr,
ExecutionTimeMilliseconds = stopwatch.ElapsedMilliseconds,
ErrorMessage = "Time limit exceeded",
UsedSandbox = true
};
}
if (stdinTask is not null)
{
await stdinTask.ConfigureAwait(false);
}
await waitForExitTask.ConfigureAwait(false);
stopwatch.Stop();
var output = await stdoutTask.ConfigureAwait(false);
var errorOutput = await stderrTask.ConfigureAwait(false);
var exitCode = process.ExitCode;
var memoryExceeded = exitCode == 137;
return new ExecutionSandboxResult
{
Success = exitCode == 0 && !memoryExceeded,
ExitCode = exitCode,
StandardOutput = output,
StandardError = errorOutput,
MemoryLimitExceeded = memoryExceeded,
ExecutionTimeMilliseconds = stopwatch.ElapsedMilliseconds,
ErrorMessage = exitCode == 0 ? null : (memoryExceeded ? "Memory limit exceeded" : $"Runtime error (exit code {exitCode})"),
UsedSandbox = true
};
}
private async Task<bool> EnsureDockerImageAvailableAsync(string executionImage, CancellationToken cancellationToken)
{
try
{
using var inspectProcess = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = _options.DockerExecutable,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false
}
};
inspectProcess.StartInfo.ArgumentList.Add("image");
inspectProcess.StartInfo.ArgumentList.Add("inspect");
inspectProcess.StartInfo.ArgumentList.Add("--format");
inspectProcess.StartInfo.ArgumentList.Add("{{.Id}}");
inspectProcess.StartInfo.ArgumentList.Add(executionImage);
inspectProcess.Start();
var stdOutTask = inspectProcess.StandardOutput.ReadToEndAsync();
var stdErrTask = inspectProcess.StandardError.ReadToEndAsync();
await inspectProcess.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
var stdOut = (await stdOutTask.ConfigureAwait(false)).Trim();
var stdErr = (await stdErrTask.ConfigureAwait(false)).Trim();
if (inspectProcess.ExitCode == 0)
{
if (!string.IsNullOrEmpty(stdOut))
{
_logger.LogDebug("Docker image {Image} available: {ImageId}", executionImage, stdOut);
}
return true;
}
if (!string.IsNullOrEmpty(stdErr))
{
_logger.LogWarning("Docker image inspect failed for {Image}: {Error}", executionImage, stdErr);
}
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to inspect docker image {Image}", executionImage);
}
return false;
}
private async Task KillContainerAsync(string containerName)
{
try
{
using var killProcess = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = _options.DockerExecutable,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false
}
};
killProcess.StartInfo.ArgumentList.Add("kill");
killProcess.StartInfo.ArgumentList.Add(containerName);
killProcess.Start();
await killProcess.WaitForExitAsync();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to kill docker container {ContainerName}", containerName);
}
}
}

View File

@@ -0,0 +1,27 @@
namespace LiquidCode.Tester.Worker.Services.Sandbox;
public class ExecutionSandboxRequest
{
public required string Language { get; init; }
public required string WorkingDirectory { get; init; }
public required string Executable { get; init; }
public IList<string> Arguments { get; init; } = new List<string>();
public string? StandardInputFile { get; init; }
public int TimeLimitMilliseconds { get; init; }
public int MemoryLimitMegabytes { get; init; }
public bool AllowNetwork { get; init; }
}
public class ExecutionSandboxResult
{
public bool Success { get; init; }
public bool TimedOut { get; init; }
public bool MemoryLimitExceeded { get; init; }
public int ExitCode { get; init; }
public string StandardOutput { get; init; } = string.Empty;
public string StandardError { get; init; } = string.Empty;
public long? MemoryUsageBytes { get; init; }
public long ExecutionTimeMilliseconds { get; init; }
public string? ErrorMessage { get; init; }
public bool UsedSandbox { get; init; }
}

View File

@@ -0,0 +1,9 @@
using System.Threading;
using System.Threading.Tasks;
namespace LiquidCode.Tester.Worker.Services.Sandbox;
public interface IExecutionSandbox
{
Task<ExecutionSandboxResult> RunAsync(ExecutionSandboxRequest request, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,21 @@
namespace LiquidCode.Tester.Worker.Services.Sandbox;
public class SandboxOptions
{
public bool Enabled { get; set; }
public string DockerExecutable { get; set; } = "docker";
public double DefaultCpuLimit { get; set; } = 1.0;
public int DefaultMemoryLimitMb { get; set; } = 512;
public int DefaultPidsLimit { get; set; } = 128;
public IDictionary<string, SandboxLanguageOptions> Languages { get; set; } =
new Dictionary<string, SandboxLanguageOptions>(StringComparer.OrdinalIgnoreCase);
}
public class SandboxLanguageOptions
{
public string? ExecutionImage { get; set; }
public string? CompilationImage { get; set; }
public double? CpuLimit { get; set; }
public int? MemoryLimitMb { get; set; }
public int? PidsLimit { get; set; }
}

View File

@@ -4,5 +4,8 @@
"Default": "Debug",
"Microsoft.AspNetCore": "Information"
}
},
"Sandbox": {
"Enabled": true
}
}

View File

@@ -86,5 +86,19 @@
"Executable": "python3.11"
}
}
},
"Sandbox": {
"Enabled": true,
"DockerExecutable": "docker",
"DefaultCpuLimit": 1.0,
"DefaultMemoryLimitMb": 512,
"DefaultPidsLimit": 128,
"Languages": {
"cpp": { "ExecutionImage": "gcc:13" },
"csharp": { "ExecutionImage": "mcr.microsoft.com/dotnet/runtime:9.0" },
"java": { "ExecutionImage": "eclipse-temurin:17-jre" },
"kotlin": { "ExecutionImage": "eclipse-temurin:17-jre" },
"python": { "ExecutionImage": "python:3.11-slim" }
}
}
}

View File

@@ -1,4 +1,5 @@
using LiquidCode.Tester.Worker.Services;
using LiquidCode.Tester.Worker.Services.Sandbox;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
@@ -32,6 +33,7 @@ public class ExecutionServiceFactoryTests
services.AddSingleton<KotlinExecutionService>();
services.AddSingleton<CSharpExecutionService>();
services.AddSingleton<PythonExecutionService>();
services.AddSingleton<IExecutionSandbox>(_ => Mock.Of<IExecutionSandbox>());
_serviceProvider = services.BuildServiceProvider();
_factory = new ExecutionServiceFactory(

View File

@@ -14,6 +14,7 @@
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
<PackageReference Include="Moq" Version="4.20.70" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.0" />
</ItemGroup>

View File

@@ -1,10 +1,13 @@
using System;
using System.Diagnostics;
using System.IO;
using System.IO.Compression;
using LiquidCode.Tester.Worker.Services;
using LiquidCode.Tester.Worker.Services.Sandbox;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Xunit;
namespace LiquidCode.Tester.Worker.Tests;
@@ -12,28 +15,45 @@ namespace LiquidCode.Tester.Worker.Tests;
public class PolygonPackageIntegrationTests
{
private readonly PackageParserService _parserService;
private readonly SandboxOptions _sandboxOptions;
public PolygonPackageIntegrationTests()
{
var configuration = new ConfigurationBuilder().Build();
var configuration = BuildConfiguration();
var cppCompilation = new CppCompilationService(NullLogger<CppCompilationService>.Instance, configuration);
var cppExecution = new CppExecutionService(NullLogger<CppExecutionService>.Instance);
var loggerFactory = LoggerFactory.Create(builder =>
{
builder.SetMinimumLevel(LogLevel.Debug);
builder.AddConsole();
});
_sandboxOptions = new SandboxOptions();
configuration.GetSection("Sandbox").Bind(_sandboxOptions);
if (!_sandboxOptions.Enabled)
{
_sandboxOptions.Enabled = true;
}
var cppCompilation = new CppCompilationService(loggerFactory.CreateLogger<CppCompilationService>(), configuration);
var sandbox = new ExecutionSandbox(Options.Create(_sandboxOptions), loggerFactory.CreateLogger<ExecutionSandbox>());
var cppExecution = new CppExecutionService(sandbox, loggerFactory.CreateLogger<CppExecutionService>());
var services = new ServiceCollection();
services.AddSingleton<IConfiguration>(configuration);
services.AddSingleton(cppCompilation);
services.AddSingleton<IExecutionSandbox>(sandbox);
services.AddSingleton(cppExecution);
var serviceProvider = services.BuildServiceProvider();
var compilationFactory = new CompilationServiceFactory(serviceProvider, NullLogger<CompilationServiceFactory>.Instance);
var executionFactory = new ExecutionServiceFactory(serviceProvider, NullLogger<ExecutionServiceFactory>.Instance);
var answerGenerator = new AnswerGenerationService(compilationFactory, executionFactory, NullLogger<AnswerGenerationService>.Instance);
var polygonParser = new PolygonProblemXmlParser(NullLogger<PolygonProblemXmlParser>.Instance);
var compilationFactory = new CompilationServiceFactory(serviceProvider, loggerFactory.CreateLogger<CompilationServiceFactory>());
var executionFactory = new ExecutionServiceFactory(serviceProvider, loggerFactory.CreateLogger<ExecutionServiceFactory>());
var answerGenerator = new AnswerGenerationService(compilationFactory, executionFactory, loggerFactory.CreateLogger<AnswerGenerationService>());
var polygonParser = new PolygonProblemXmlParser(loggerFactory.CreateLogger<PolygonProblemXmlParser>());
_parserService = new PackageParserService(
NullLogger<PackageParserService>.Instance,
loggerFactory.CreateLogger<PackageParserService>(),
polygonParser,
answerGenerator,
cppCompilation);
@@ -47,6 +67,20 @@ public class PolygonPackageIntegrationTests
return;
}
var dockerExecutable = string.IsNullOrWhiteSpace(_sandboxOptions.DockerExecutable)
? "docker"
: _sandboxOptions.DockerExecutable;
if (!IsExecutableAvailable(dockerExecutable))
{
return;
}
Assert.True(
_sandboxOptions.Languages.TryGetValue("cpp", out var cppLanguage) &&
!string.IsNullOrWhiteSpace(cppLanguage.ExecutionImage),
"Sandbox configuration must specify an execution image for C++.");
var packageDirectory = ResolvePackageDirectory("exam-queue-17");
Assert.True(Directory.Exists(packageDirectory));
@@ -70,13 +104,16 @@ public class PolygonPackageIntegrationTests
}
finally
{
if (!string.IsNullOrEmpty(package.ExtractionRoot) && Directory.Exists(package.ExtractionRoot))
if (!KeepTemporaryExtraction())
{
Directory.Delete(package.ExtractionRoot, recursive: true);
}
else if (Directory.Exists(package.WorkingDirectory))
{
Directory.Delete(package.WorkingDirectory, recursive: true);
if (!string.IsNullOrEmpty(package.ExtractionRoot) && Directory.Exists(package.ExtractionRoot))
{
Directory.Delete(package.ExtractionRoot, recursive: true);
}
else if (Directory.Exists(package.WorkingDirectory))
{
Directory.Delete(package.WorkingDirectory, recursive: true);
}
}
}
}
@@ -143,10 +180,29 @@ public class PolygonPackageIntegrationTests
return memoryStream;
}
private static string ResolvePackageDirectory(string folderName)
private static IConfiguration BuildConfiguration()
{
var repositoryRoot = ResolveRepositoryRoot();
return new ConfigurationBuilder()
.SetBasePath(repositoryRoot)
.AddJsonFile("src/LiquidCode.Tester.Worker/appsettings.json", optional: false)
.AddJsonFile("src/LiquidCode.Tester.Worker/appsettings.Development.json", optional: true)
.AddEnvironmentVariables()
.Build();
}
private static string ResolveRepositoryRoot()
{
var baseDirectory = AppContext.BaseDirectory;
var root = Path.GetFullPath(Path.Combine(baseDirectory, "..", "..", "..", "..", ".."));
return Path.Combine(root, folderName);
return Path.GetFullPath(Path.Combine(baseDirectory, "..", "..", "..", "..", ".."));
}
private static string ResolvePackageDirectory(string folderName)
{
return Path.Combine(ResolveRepositoryRoot(), folderName);
}
private static bool KeepTemporaryExtraction() =>
string.Equals(Environment.GetEnvironmentVariable("LC_KEEP_POLYGON_TEMP"), "1", StringComparison.Ordinal);
}