From 414f404d6ba38ec3177647db74562e839a96f41f Mon Sep 17 00:00:00 2001 From: harichillin Date: Tue, 3 Nov 2026 12:39:42 +0530 Subject: [PATCH] fix(sandbox): compile rust as edition 2021 rustc defaults to edition 2015 when --edition is not given, so solutions using try_into, TryFrom or FromIterator from the 2021 prelude failed to compile. Rust now compiles with --edition=2021 by default. The default lives in a new LangConfig.CompileDefaults field: a caller flag with the same name replaces it instead of following it, because rustc rejects --edition given twice and compiler_flags already allows --edition=2018. Closes #117 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + README.md | 2 +- internal/sandbox/language.go | 73 +++++++++++----- internal/sandbox/language_test.go | 59 ++++++++++++- internal/worker/rust_test.go | 141 ++++++++++++++++++++++++++++++ 5 files changed, 252 insertions(+), 24 deletions(-) create mode 100644 internal/worker/rust_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index dab2e2b..452454c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ and uses [semantic versioning](https://semver.org/). - **One Redis subscription per gateway** — SSE clients share a single pattern subscription instead of one subscription each. A client that falls behind is closed so it reconnects and replays from the event log; no event is dropped silently. - **Honest `Retry-After`** — when the queue is full, `429` carries the time the queue needs to fall under the admission limit at the measured completion rate (1 to 60 s), not a fixed 10. - **Leaner JVM** — `java` and `kotlin` start with the serial collector and shared class data, which also lowers the thread count of each run. +- **Rust edition 2021** — `rust` compiles with `--edition=2021` instead of rustc's default 2015, so `try_into`, `TryFrom` and `FromIterator` work without imports. `compiler_flags: ["--edition=2018"]` still works: it replaces the default rather than adding a second `--edition`, which rustc rejects. ## [0.1.0] — 2026-10-04 diff --git a/README.md b/README.md index b116737..d28ca9b 100644 --- a/README.md +++ b/README.md @@ -500,7 +500,7 @@ Cancels an active or queued submission. Marks the submission record as `failed` | `javascript` | Node.js 24 LTS | No | 2.0 s | 512 MB | | `sqlite` | SQLite 3.45 (`sqlite3 -batch`) | No | 2.0 s | 512 MB | | `bash` | Bash | No | 2.0 s | 256 MB | -| `rust` | rustc (`-O`) | Yes | 2.0 s | 512 MB | +| `rust` | rustc 1.85 (`-O --edition=2021`) | Yes | 2.0 s | 512 MB | | `csharp` | Mono (`mcs`, `mono`) | Yes | 2.0 s | 512 MB (Min 128 MB) | | `php` | PHP CLI | No | 2.0 s | 512 MB | | `ruby` | Ruby | No | 2.0 s | 512 MB | diff --git a/internal/sandbox/language.go b/internal/sandbox/language.go index 654732f..6cab687 100644 --- a/internal/sandbox/language.go +++ b/internal/sandbox/language.go @@ -1,5 +1,10 @@ package sandbox +import ( + "slices" + "strings" +) + // StdoutMax and StderrMax are the per-test output caps sent to go-judge. // They default to 8 MiB and 1 MiB respectively and can be overridden at // startup via AC_SANDBOX_STDOUT_MAX_BYTES / AC_SANDBOX_STDERR_MAX_BYTES. @@ -63,12 +68,17 @@ func ClockLimit(cpu uint64) uint64 { type LangConfig struct { Compiled bool CompileArgs []string - CompileEnv []string - CompileCPU uint64 // nanoseconds - CompileMem uint64 // bytes - CompileProc uint64 - SourcePath string - BinaryPath string // output path for compiled languages + // CompileDefaults are flags appended after CompileArgs unless a caller + // flag has the same name (the text before '='), which then replaces it. + // rustc rejects an option given twice, so a caller's --edition=2018 must + // replace the default edition, not follow it. + CompileDefaults []string + CompileEnv []string + CompileCPU uint64 // nanoseconds + CompileMem uint64 // bytes + CompileProc uint64 + SourcePath string + BinaryPath string // output path for compiled languages RunArgs []string RunEnv []string @@ -217,17 +227,20 @@ var Languages = map[string]LangConfig{ "rust": { Compiled: true, CompileArgs: []string{"/usr/bin/rustc", "-O", "-o", "/w/a.out", "/w/main.rs"}, - CompileEnv: []string{"PATH=/usr/bin:/bin", "HOME=/tmp"}, - CompileCPU: 20_000_000_000, - CompileMem: 536_870_912, - CompileProc: 50, - SourcePath: "/w/main.rs", - BinaryPath: "/w/a.out", - RunArgs: []string{"/w/a.out"}, - RunEnv: []string{"PATH=/usr/bin:/bin"}, - RunCPU: 2_000_000_000, - RunMem: 536_870_912, - RunProc: 10, + // Without --edition rustc compiles as edition 2015, where TryFrom, + // TryInto and FromIterator are not in the prelude. + CompileDefaults: []string{"--edition=2021"}, + CompileEnv: []string{"PATH=/usr/bin:/bin", "HOME=/tmp"}, + CompileCPU: 20_000_000_000, + CompileMem: 536_870_912, + CompileProc: 50, + SourcePath: "/w/main.rs", + BinaryPath: "/w/a.out", + RunArgs: []string{"/w/a.out"}, + RunEnv: []string{"PATH=/usr/bin:/bin"}, + RunCPU: 2_000_000_000, + RunMem: 536_870_912, + RunProc: 10, }, "typescript": { Compiled: false, @@ -265,11 +278,7 @@ func BuildCompileCmd(lang LangConfig, sourceCode string) Cmd { // BuildCompileCmdWithOptions builds the go-judge Cmd for compilation supporting extra files and compiler flags. func BuildCompileCmdWithOptions(lang LangConfig, sourceCode string, extraFiles map[string]string, compilerFlags []string) Cmd { - args := make([]string, 0, len(lang.CompileArgs)+len(compilerFlags)) - args = append(args, lang.CompileArgs...) - if len(compilerFlags) > 0 { - args = append(args, compilerFlags...) - } + args := compileArgs(lang, compilerFlags) copyIn := make(map[string]CopyIn, 1+len(extraFiles)) copyIn[lang.SourcePath] = FileContent(sourceCode) @@ -293,6 +302,26 @@ func BuildCompileCmdWithOptions(lang LangConfig, sourceCode string, extraFiles m } } +// compileArgs returns CompileArgs, then each CompileDefaults flag the caller +// did not override, then the caller's flags. +func compileArgs(lang LangConfig, compilerFlags []string) []string { + args := make([]string, 0, len(lang.CompileArgs)+len(lang.CompileDefaults)+len(compilerFlags)) + args = append(args, lang.CompileArgs...) + for _, def := range lang.CompileDefaults { + name := flagName(def) + if !slices.ContainsFunc(compilerFlags, func(f string) bool { return flagName(f) == name }) { + args = append(args, def) + } + } + return append(args, compilerFlags...) +} + +// flagName is a flag without its "=value" part: "--edition=2021" → "--edition". +func flagName(flag string) string { + name, _, _ := strings.Cut(flag, "=") + return name +} + // BuildCacheSourceCmd builds a no-op go-judge Cmd that uploads interpreted source // into the file cache. The returned fileID can then be used in BuildRunCmd to avoid // sending the full source text in every test Cmd of a batch. diff --git a/internal/sandbox/language_test.go b/internal/sandbox/language_test.go index f847a2a..414e914 100644 --- a/internal/sandbox/language_test.go +++ b/internal/sandbox/language_test.go @@ -1,6 +1,9 @@ package sandbox -import "testing" +import ( + "slices" + "testing" +) func TestEffectiveDefaultsWithoutLimits(t *testing.T) { lang := Languages["cpp"] @@ -30,3 +33,57 @@ func TestBuildRunCmdSetsLimitsAndClock(t *testing.T) { t.Fatalf("cpu=%d mem=%d clock=%d", cmd.CPULimit, cmd.MemoryLimit, cmd.ClockLimit) } } + +func TestBuildCompileCmdRustDefaults(t *testing.T) { + rustc := []string{"/usr/bin/rustc", "-O", "-o", "/w/a.out", "/w/main.rs"} + tests := []struct { + name string + flags []string + want []string + }{ + {"no flags compile as edition 2021", nil, append(slices.Clone(rustc), "--edition=2021")}, + {"caller edition replaces the default", []string{"--edition=2018"}, append(slices.Clone(rustc), "--edition=2018")}, + {"caller 2021 is not repeated", []string{"--edition=2021"}, append(slices.Clone(rustc), "--edition=2021")}, + // rustc accepts -O twice, so a caller -O keeps the default edition. + {"other flags keep the default", []string{"-O"}, append(slices.Clone(rustc), "--edition=2021", "-O")}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := BuildCompileCmdWithOptions(Languages["rust"], "fn main(){}", nil, tt.flags).Args + if !slices.Equal(got, tt.want) { + t.Fatalf("args = %q, want %q", got, tt.want) + } + }) + } +} + +// A language without CompileDefaults gets its args and the caller's flags, as before. +func TestBuildCompileCmdWithoutDefaults(t *testing.T) { + cpp := Languages["cpp"] + got := BuildCompileCmdWithOptions(cpp, "int main(){}", nil, []string{"-O3"}).Args + want := append(slices.Clone(cpp.CompileArgs), "-O3") + if !slices.Equal(got, want) { + t.Fatalf("args = %q, want %q", got, want) + } +} + +// No allowed caller flag may repeat a default: rustc fails with "Option +// 'edition' given more than once" when --edition appears twice. +func TestBuildCompileCmdNeverRepeatsAllowedFlag(t *testing.T) { + for language, allowed := range AllowedCompilerFlags { + lang, ok := Languages[language] + if !ok || len(lang.CompileDefaults) == 0 { + continue + } + for flag := range allowed { + args := BuildCompileCmdWithOptions(lang, "", nil, []string{flag}).Args + seen := map[string]bool{} + for _, arg := range args[len(lang.CompileArgs):] { + if seen[flagName(arg)] { + t.Errorf("%s with %q: %q repeats %s", language, flag, args, flagName(arg)) + } + seen[flagName(arg)] = true + } + } + } +} diff --git a/internal/worker/rust_test.go b/internal/worker/rust_test.go new file mode 100644 index 0000000..c5affb2 --- /dev/null +++ b/internal/worker/rust_test.go @@ -0,0 +1,141 @@ +package worker + +import ( + "context" + "encoding/json" + "log/slog" + "net/http" + "net/http/httptest" + "slices" + "strings" + "sync" + "testing" + "time" + + "github.com/stjosephsplacements/AetherCode-Execution-Engine/internal/model" + "github.com/stjosephsplacements/AetherCode-Execution-Engine/internal/sandbox" +) + +const rustSolution = `use std::io::{self, Read}; +fn main() { + let mut s = String::new(); + io::stdin().read_to_string(&mut s).unwrap(); + let n: i64 = s.trim().parse().unwrap(); + let small: u8 = n.try_into().unwrap_or(255); + println!("{}", small); +} +` + +// rustJudge is a fake go-judge for Rust jobs. Unit tests never run real +// submissions, so it answers the compile request with compileResult (adding +// a cached binary ID when the compile is accepted) and every run Cmd with +// stdout "42". It records every request it receives. +func rustJudge(t *testing.T, compileResult sandbox.Result) (*Worker, func() []sandbox.Request) { + t.Helper() + var mu sync.Mutex + var reqs []sandbox.Request + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req sandbox.Request + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + mu.Lock() + reqs = append(reqs, req) + mu.Unlock() + res := make([]sandbox.Result, len(req.Cmd)) + for i, cmd := range req.Cmd { + if slices.Contains(cmd.CopyOutCached, "/w/a.out") { + res[i] = compileResult + if compileResult.Status == sandbox.StatusAccepted { + res[i].FileIDs = map[string]string{"/w/a.out": "rust-bin"} + } + continue + } + res[i] = sandbox.Result{Status: sandbox.StatusAccepted, Files: map[string]string{"stdout": "42\n"}} + } + _ = json.NewEncoder(w).Encode(res) + })) + t.Cleanup(srv.Close) + + w := New(nil, sandbox.NewClient(srv.URL, "dummy"), nil, nil, 10*time.Second, 1, 5*time.Minute) + return w, func() []sandbox.Request { + mu.Lock() + defer mu.Unlock() + return append([]sandbox.Request(nil), reqs...) + } +} + +// A Rust job compiles main.rs as edition 2021 and runs the cached binary for +// every test. +func TestRustCompilesAsEdition2021AndRunsTests(t *testing.T) { + w, reqs := rustJudge(t, sandbox.Result{Status: sandbox.StatusAccepted}) + lang := sandbox.Languages["rust"] + + compiled, err := w.compileWithOptions(context.Background(), lang, rustSolution, nil, nil) + if err != nil { + t.Fatalf("compile: %v", err) + } + if compiled.Status != sandbox.StatusAccepted || compiled.FileIDs["/w/a.out"] != "rust-bin" { + t.Fatalf("compile result = %+v, want accepted with the cached binary", compiled) + } + + tests := []testData{{Input: "42", ExpectedOutput: "42"}, {Input: "42", ExpectedOutput: "42"}} + _, _, _, passed, verdict, err := w.runTestsBatched(context.Background(), "job-rust", lang, rustSolution, tests, "rust-bin", sandbox.RunLimits{}, nil, slog.Default()) + if err != nil { + t.Fatalf("runTestsBatched: %v", err) + } + if passed != 2 || verdict != model.VerdictAccepted { + t.Fatalf("passed=%d verdict=%v, want 2 Accepted", passed, verdict) + } + + got := reqs() + if len(got) != 2 { + t.Fatalf("want a compile request and one test batch, got %d requests", len(got)) + } + compile := got[0].Cmd[0] + if compile.Args[0] != "/usr/bin/rustc" || !slices.Contains(compile.Args, "--edition=2021") { + t.Errorf("compile args = %q, want rustc with --edition=2021", compile.Args) + } + if src := compile.CopyIn["/w/main.rs"].Content; src == nil || *src != rustSolution { + t.Errorf("compile did not copy the source to /w/main.rs") + } + for i, cmd := range got[1].Cmd { + if !slices.Equal(cmd.Args, lang.RunArgs) { + t.Errorf("test %d args = %q, want %q", i, cmd.Args, lang.RunArgs) + } + if id := cmd.CopyIn["/w/a.out"].FileID; id == nil || *id != "rust-bin" { + t.Errorf("test %d does not run the compiled binary", i) + } + } +} + +// A caller's --edition replaces the default instead of following it: rustc +// fails with "Option 'edition' given more than once" otherwise. +func TestRustCallerEditionReplacesDefault(t *testing.T) { + w, reqs := rustJudge(t, sandbox.Result{Status: sandbox.StatusAccepted}) + + if _, err := w.compileWithOptions(context.Background(), sandbox.Languages["rust"], rustSolution, nil, []string{"--edition=2018"}); err != nil { + t.Fatalf("compile: %v", err) + } + args := reqs()[0].Cmd[0].Args + editions := slices.DeleteFunc(slices.Clone(args), func(a string) bool { return !strings.HasPrefix(a, "--edition") }) + if !slices.Equal(editions, []string{"--edition=2018"}) { + t.Fatalf("compile args = %q, want exactly one --edition=2018", args) + } +} + +// A Rust compile failure comes back as a non-accepted result carrying +// rustc's diagnostics, which process turns into a Compilation Error verdict. +func TestRustCompileErrorKeepsDiagnostics(t *testing.T) { + stderr := "error[E0308]: mismatched types\n" + w, _ := rustJudge(t, sandbox.Result{Status: sandbox.StatusNonzeroExit, ExitStatus: 1, Files: map[string]string{"stderr": stderr}}) + + res, err := w.compileWithOptions(context.Background(), sandbox.Languages["rust"], `fn main() { let x: i32 = "nope"; }`, nil, nil) + if err != nil { + t.Fatalf("compile: %v", err) + } + if res.Status == sandbox.StatusAccepted || res.Files["stderr"] != stderr { + t.Fatalf("compile result = %+v, want a failure with rustc's stderr", res) + } +}