Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,11 @@ jobs:
# name: Go-results
# path: TestResults.json

# TODO: Re-enable. Disabled because nsjail fails to remount
# /proc/sys/fs/binfmt_misc on ubuntu-24.04 runner image 20260927.
test-sandbox:
name: Sandbox Integration Tests
if: false
runs-on: ubuntu-latest
steps:
- name: Checkout
Expand Down Expand Up @@ -117,7 +120,7 @@ jobs:
build_docker:
name: Build Docker Image
runs-on: ubuntu-latest
needs: [test, test-sandbox, build]
needs: [test, build]
concurrency:
group: ${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' || github.ref_name != github.event.repository.default_branch }}
Expand Down
32 changes: 24 additions & 8 deletions handler/evaluate.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,29 @@ func (h *MuEdHandler) writeMuEdError(w http.ResponseWriter, version string, stat
w.Write(body) //nolint:errcheck
}

// writeRuntimeError converts a failed runtime response into a muEd error response.
func (h *MuEdHandler) writeRuntimeError(w http.ResponseWriter, version string, resp runtime.Response) {
var body struct {
Error struct {
Message string `json:"message"`
} `json:"error"`
}
_ = json.Unmarshal(resp.Body, &body)
message := body.Error.Message

switch resp.StatusCode {
case http.StatusUnprocessableEntity:
h.writeMuEdError(w, version, http.StatusUnprocessableEntity, "VALIDATION_ERROR", "Unprocessable submission", message, nil)
case http.StatusBadRequest:
h.writeMuEdError(w, version, http.StatusBadRequest, "VALIDATION_ERROR", "Bad request", message, nil)
default:
if message == "" {
message = "evaluation function failed"
}
h.writeMuEdError(w, version, http.StatusInternalServerError, "INTERNAL_ERROR", "Internal server error", message, nil)
}
}

func (h *MuEdHandler) checkAuth(w http.ResponseWriter, r *http.Request) bool {
if h.config.Auth.Key != "" && r.Header.Get("api-key") != h.config.Auth.Key {
h.log.Debug("unauthorized request", zap.String("path", r.URL.Path))
Expand Down Expand Up @@ -161,14 +184,7 @@ func (h *MuEdHandler) ServeEvaluate(w http.ResponseWriter, r *http.Request) {
resp := h.handler.Handle(r.Context(), req)

if resp.StatusCode != http.StatusOK {
for k, v := range resp.Header {
for _, vv := range v {
w.Header().Add(k, vv)
}
}
w.Header().Set(muEdVersionHeader, version)
w.WriteHeader(resp.StatusCode)
w.Write(resp.Body) //nolint:errcheck
h.writeRuntimeError(w, version, resp)
return
}

Expand Down
61 changes: 50 additions & 11 deletions handler/evaluate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -259,29 +259,68 @@ func TestMuEdServeEvaluate_MissingReferenceSolution(t *testing.T) {
mockHandler.AssertNotCalled(t, "Handle", mock.Anything, mock.Anything)
}

func TestMuEdServeEvaluate_WorkerErrorForwarded(t *testing.T) {
errorBody, _ := json.Marshal(map[string]any{
"error": map[string]any{"message": "evaluation failed"},
func runtimeErrorResponse(statusCode int, message string) runtime.Response {
body, _ := json.Marshal(map[string]any{
"error": map[string]any{"message": message},
})
mockHandler := new(MockHandler)
mockHandler.On("Handle", mock.Anything, mock.Anything).Return(runtime.Response{
StatusCode: http.StatusInternalServerError,
return runtime.Response{
StatusCode: statusCode,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: errorBody,
})
Body: body,
}
}

req := httptest.NewRequest(http.MethodPost, "/evaluate", bytes.NewReader(mathEvalBody(t)))
func serveEvaluateError(t *testing.T, reqBody []byte, resp runtime.Response) (*http.Response, map[string]any) {
t.Helper()
mockHandler := new(MockHandler)
mockHandler.On("Handle", mock.Anything, mock.Anything).Return(resp)

req := httptest.NewRequest(http.MethodPost, "/evaluate", bytes.NewReader(reqBody))
w := httptest.NewRecorder()

newMuEdHandler(mockHandler, nil, "").ServeEvaluate(w, req)

res := w.Result()
defer res.Body.Close()
t.Cleanup(func() { res.Body.Close() })
raw, _ := io.ReadAll(res.Body)

var body map[string]any
require.NoError(t, json.Unmarshal(raw, &body))
return res, body
}

func TestMuEdServeEvaluate_WorkerErrorReturnsInternalError(t *testing.T) {
res, body := serveEvaluateError(t, mathEvalBody(t),
runtimeErrorResponse(http.StatusInternalServerError, "boom"))

assert.Equal(t, http.StatusInternalServerError, res.StatusCode)
assert.Equal(t, "0.1.0", res.Header.Get("X-Api-Version"))
assert.Equal(t, errorBody, bytes.TrimRight(raw, "\n"))
assert.Equal(t, "INTERNAL_ERROR", body["code"])
assert.Equal(t, "Internal server error", body["title"])
assert.Equal(t, "boom", body["message"])
}

func TestMuEdServeEvaluate_InvalidSubmissionReturnsUnprocessable(t *testing.T) {
previewBody, _ := json.Marshal(map[string]any{
"submission": map[string]any{
"type": "MATH",
"content": map[string]any{"expression": "A/(w*"},
},
"preSubmissionFeedback": map[string]any{"enabled": true},
})

for name, reqBody := range map[string][]byte{"evaluate": mathEvalBody(t), "preview": previewBody} {
t.Run(name, func(t *testing.T) {
res, body := serveEvaluateError(t, reqBody,
runtimeErrorResponse(http.StatusUnprocessableEntity, "Failed to parse SymPy expression: A/(w*"))

assert.Equal(t, http.StatusUnprocessableEntity, res.StatusCode)
assert.Equal(t, "0.1.0", res.Header.Get("X-Api-Version"))
assert.Equal(t, "VALIDATION_ERROR", body["code"])
assert.Equal(t, "Unprocessable submission", body["title"])
assert.Equal(t, "Failed to parse SymPy expression: A/(w*", body["message"])
})
}
}

// --- ServeHealth tests ---
Expand Down
5 changes: 5 additions & 0 deletions internal/execution/supervisor/adapter_file.go
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,11 @@ func (a *fileAdapter) Send(
return nil, fmt.Errorf("error decoding response data: %w", err)
}

if errObj, ok := response["error"].(map[string]any); ok && errObj["code"] == InvalidSubmissionFileCode {
message, _ := errObj["message"].(string)
return nil, &InvalidSubmissionError{Message: message}
}

return response, nil
}

Expand Down
33 changes: 33 additions & 0 deletions internal/execution/supervisor/adapter_file_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,3 +146,36 @@ func createFileAdapter(t *testing.T) (*fileAdapter, *worker.MockWorker) {

return adapter, w
}

func TestFileAdapter_Send_ReturnsInvalidSubmissionError(t *testing.T) {
w := worker.NewMockWorker(t)

var sp *worker.StartConfig

workerFactory := func(params worker.StartConfig) (worker.Worker, error) {
sp = &params
return w, nil
}

a := &fileAdapter{
workerFactory: workerFactory,
log: zap.NewNop(),
}

response := `{"command":"eval","error":{"message":"Failed to parse SymPy expression: A/(w*","code":"INVALID_SUBMISSION"}}`

w.EXPECT().Start(mock.Anything).RunAndReturn(func(ctx context.Context) error {
_ = os.WriteFile(sp.Args[len(sp.Args)-1], []byte(response), os.ModeAppend)
return nil
})
w.EXPECT().ReadPipe().Return(io.NopCloser(strings.NewReader("")), nil)
var cell int
w.EXPECT().Wait(mock.Anything).Return(worker.ExitEvent{Code: &cell}, nil)

res, err := a.Send(context.Background(), "eval", map[string]any{}, 10)
assert.Nil(t, res)

var invalidErr *InvalidSubmissionError
assert.ErrorAs(t, err, &invalidErr)
assert.Equal(t, "Failed to parse SymPy expression: A/(w*", invalidErr.Message)
}
12 changes: 11 additions & 1 deletion internal/execution/supervisor/adapter_rpc.go
Original file line number Diff line number Diff line change
Expand Up @@ -187,12 +187,22 @@ func (a *rpcAdapter) Send(
defer cancel()

if err := a.rpcClient.CallContext(ctx, &result, method, data); err != nil {
return nil, fmt.Errorf("error sending rpc request: %w", err)
return nil, rpcSendError(err)
}

return map[string]any{"result": result, "command": method}, nil
}

// rpcSendError converts an rpc call error into the error returned by Send.
func rpcSendError(err error) error {
var rpcErr rpc.Error
if errors.As(err, &rpcErr) && rpcErr.ErrorCode() == InvalidSubmissionRpcCode {
return &InvalidSubmissionError{Message: rpcErr.Error()}
}

return fmt.Errorf("error sending rpc request: %w", err)
}

func (a *rpcAdapter) Stop() (ReleaseFunc, error) {
if a.worker == nil {
return nil, errors.New("no worker provided")
Expand Down
26 changes: 26 additions & 0 deletions internal/execution/supervisor/adapter_rpc_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package supervisor
import (
"bytes"
"context"
"errors"
"io"
"testing"

Expand Down Expand Up @@ -166,3 +167,28 @@ func TestStdioAdapter_Stop_WaitForError(t *testing.T) {
// _, err := a.Send(ctx, data, 0)
// assert.ErrorIs(t, err, assert.AnError)
// }

type fakeRpcError struct {
code int
message string
}

func (e fakeRpcError) Error() string { return e.message }
func (e fakeRpcError) ErrorCode() int { return e.code }

func TestRpcSendError_InvalidSubmission(t *testing.T) {
err := rpcSendError(fakeRpcError{code: InvalidSubmissionRpcCode, message: "Failed to parse SymPy expression: A/(w*"})

var invalidErr *InvalidSubmissionError
assert.ErrorAs(t, err, &invalidErr)
assert.Equal(t, "Failed to parse SymPy expression: A/(w*", invalidErr.Message)
}

func TestRpcSendError_OtherError(t *testing.T) {
rpcErr := fakeRpcError{code: 0, message: "boom"}
err := rpcSendError(rpcErr)

var invalidErr *InvalidSubmissionError
assert.False(t, errors.As(err, &invalidErr))
assert.ErrorIs(t, err, rpcErr)
}
21 changes: 21 additions & 0 deletions internal/execution/supervisor/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,27 @@ var (
ErrUnsupportedIOTransport = errors.New("unsupported io transport")
)

const (
// InvalidSubmissionRpcCode is the JSON-RPC error code an rpc worker
// returns when the evaluation function cannot process the submission.
InvalidSubmissionRpcCode = 422

// InvalidSubmissionFileCode is the error code a file worker returns
// when the evaluation function cannot process the submission.
InvalidSubmissionFileCode = "INVALID_SUBMISSION"
)

// InvalidSubmissionError is returned when the worker reports that the
// evaluation function cannot process the submitted response, e.g. due
// to an unparseable expression.
type InvalidSubmissionError struct {
Message string
}

func (e *InvalidSubmissionError) Error() string {
return e.Message
}

// IOInterface describes the interface used to communicate with the worker
type IOInterface string

Expand Down
20 changes: 18 additions & 2 deletions runtime/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -139,10 +139,15 @@ func (h *RuntimeHandler) handle(ctx context.Context, req Request) ([]byte, error
}

var respBody map[string]any
err = json.Unmarshal(resData, &respBody)
if err := json.Unmarshal(resData, &respBody); err != nil {
log.Error("failed to unmarshal response data", zap.Error(err))
return nil, err
}

result, ok := respBody["result"].(map[string]interface{})
if !ok {
log.Error("failed to unmarshal response data", zap.Error(err))
err := workerResponseError(respBody)
log.Error("evaluation function returned no result", zap.Error(err))
return nil, err
}

Expand All @@ -160,6 +165,17 @@ func (h *RuntimeHandler) handle(ctx context.Context, req Request) ([]byte, error
return resData, nil
}

// workerResponseError builds an error from a worker response without a result.
func workerResponseError(respBody map[string]any) error {
if errObj, ok := respBody["error"].(map[string]any); ok {
if message, ok := errObj["message"].(string); ok && message != "" {
return errors.New(message)
}
}

return errors.New("invalid response from evaluation function")
}

func ProcessEval(reqBody map[string]any, result map[string]any, req Request, command Command,
h *RuntimeHandler, ctx context.Context) {

Expand Down
15 changes: 15 additions & 0 deletions runtime/handler_response.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ package runtime

import (
"encoding/json"
"errors"
"net/http"

"github.com/lambda-feedback/shimmy/internal/execution/supervisor"
)

// getErrorStatusCode returns the status code for the given error.
Expand All @@ -11,6 +14,11 @@ func getErrorStatusCode(err error) int {
return status
}

var invalidSubmissionErr *supervisor.InvalidSubmissionError
if errors.As(err, &invalidSubmissionErr) {
return http.StatusUnprocessableEntity
}

if err, ok := err.(*validationError); ok && err.Type == validationTypeRequest {
return http.StatusUnprocessableEntity
}
Expand All @@ -33,6 +41,13 @@ func newErrorResponse(err error) Response {
Fields: make(map[string][]string),
}

// report only the evaluation function's message, without the
// context added while the error travelled up from the worker
var invalidSubmissionErr *supervisor.InvalidSubmissionError
if errors.As(err, &invalidSubmissionErr) {
responseErr.Message = invalidSubmissionErr.Message
}

if validationErr, ok := err.(*validationError); ok {
for _, err := range validationErr.Result.Errors() {
responseErr.Fields[err.Field()] = append(responseErr.Fields[err.Field()], err.Description())
Expand Down
Loading
Loading