diff options
| author | Shulhan <ms@kilabit.info> | 2024-09-16 00:18:39 +0700 |
|---|---|---|
| committer | Shulhan <ms@kilabit.info> | 2024-10-04 17:58:49 +0700 |
| commit | 483594273683e1734bdab7e20086c4c66bcc5b44 (patch) | |
| tree | 4ab9db3ebd3a29c3fb963b48d12ddb06ef311779 | |
| parent | 69966b7be999223dc7a1ab0c5a3339010e11433c (diff) | |
| download | pakakeh.go-483594273683e1734bdab7e20086c4c66bcc5b44.tar.xz | |
lib/play: new package for formatting and running Go code
Package play provides callable APIs and HTTP handlers to format and run
Go code, similar to Go playground, but using HTTP instead of WebSocket.
| -rw-r--r-- | lib/play/command.go | 81 | ||||
| -rw-r--r-- | lib/play/play.go | 289 | ||||
| -rw-r--r-- | lib/play/play_example_test.go | 151 | ||||
| -rw-r--r-- | lib/play/play_test.go | 315 | ||||
| -rw-r--r-- | lib/play/request.go | 55 | ||||
| -rw-r--r-- | lib/play/run_manager.go | 31 | ||||
| -rw-r--r-- | lib/play/testdata/format_test.txt | 15 | ||||
| -rw-r--r-- | lib/play/testdata/httpHandleFormat_test.txt | 42 | ||||
| -rw-r--r-- | lib/play/testdata/httpHandleRun_test.txt | 61 | ||||
| -rw-r--r-- | lib/play/testdata/run_overlap_test.txt | 56 | ||||
| -rw-r--r-- | lib/play/testdata/run_test.txt | 35 |
11 files changed, 1131 insertions, 0 deletions
diff --git a/lib/play/command.go b/lib/play/command.go new file mode 100644 index 00000000..2cbed88e --- /dev/null +++ b/lib/play/command.go @@ -0,0 +1,81 @@ +// SPDX-FileCopyrightText: 2024 M. Shulhan <ms@kilabit.info> +// +// SPDX-License-Identifier: BSD-3-Clause + +package play + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "os/exec" + "time" +) + +type command struct { + buf *bytes.Buffer + execGoRun *exec.Cmd + ctx context.Context + ctxCancel context.CancelFunc + pid chan int +} + +func newCommand(req *Request, workingDir string) (cmd *command, err error) { + const defCommandTimeout = 30 * time.Second + + cmd = &command{ + buf: &bytes.Buffer{}, + pid: make(chan int, 1), + } + var ctxParent = context.Background() + cmd.ctx, cmd.ctxCancel = context.WithTimeout(ctxParent, defCommandTimeout) + + var userHomeDir string + + userHomeDir, err = os.UserHomeDir() + if err != nil { + return nil, fmt.Errorf(`newCommand: %w`, err) + } + + cmd.execGoRun = exec.CommandContext(cmd.ctx, `go`, `run`, `-race`, `.`) + cmd.execGoRun.Env = append(cmd.execGoRun.Env, `CGO_ENABLED=1`) + cmd.execGoRun.Env = append(cmd.execGoRun.Env, `HOME=`+userHomeDir) + cmd.execGoRun.Env = append(cmd.execGoRun.Env, `PATH=/usr/bin:/usr/local/bin`) + cmd.execGoRun.Dir = workingDir + cmd.execGoRun.Stdout = cmd.buf + cmd.execGoRun.Stderr = cmd.buf + cmd.execGoRun.WaitDelay = 100 * time.Millisecond + + return cmd, nil +} + +// run the command using [exec.Command.Start] and [exec.Command.Wait]. +// The Start method is used to get the process ID. +// When the Start or Wait failed, it will write the error or ProcessState +// into the last line of out. +func (cmd *command) run() (out []byte) { + defer cmd.ctxCancel() + + var err = cmd.execGoRun.Start() + if err != nil { + cmd.buf.WriteString("\n" + err.Error() + "\n") + goto out + } + + cmd.pid <- cmd.execGoRun.Process.Pid + + err = cmd.execGoRun.Wait() + if err != nil { + var errExit *exec.ExitError + if errors.As(err, &errExit) { + cmd.buf.WriteString("\n" + errExit.ProcessState.String() + "\n") + } else { + cmd.buf.WriteString("\n" + err.Error() + "\n") + } + } +out: + out = cmd.buf.Bytes() + return out +} diff --git a/lib/play/play.go b/lib/play/play.go new file mode 100644 index 00000000..6ea179c5 --- /dev/null +++ b/lib/play/play.go @@ -0,0 +1,289 @@ +// SPDX-FileCopyrightText: 2024 M. Shulhan <ms@kilabit.info> +// +// SPDX-License-Identifier: BSD-3-Clause + +// Package play provides callable APIs and HTTP handlers to format and run +// Go code, similar to Go playground but using HTTP instead of WebSocket. +// +// For HTTP API, this package expose two handlers: [HTTPHandleFormat] and +// [HTTPHandleRun]. +// Both HTTP APIs accept JSON content type, with the following request +// format, +// +// { +// "body":<string> +// } +// +// where "body" field contains the Go code to be formatted or run. +// Both have the following response format, +// +// { +// "code": <integer, HTTP status code>, +// "name": <string, error type>, +// "message": <string, optional message>, +// "data": <string> +// } +// +// For the [HTTPHandleFormat], the response "data" contains the formatted Go +// code. +// For the [HTTPHandleRun], the response "data" contains the output from +// running the Go code, the "message" contains an error pre-Run, like bad +// request or file system related error. +package play + +import ( + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "path/filepath" + "time" + + liberrors "git.sr.ht/~shulhan/pakakeh.go/lib/errors" + libhttp "git.sr.ht/~shulhan/pakakeh.go/lib/http" + "golang.org/x/tools/imports" +) + +// GoVersion define the Go tool version for go.mod to be used to run the +// code. +var GoVersion = `1.23.2` + +var gomodTemplate = ` +module play.local + +go ` + GoVersion + ` +` + +var now = func() int64 { + return time.Now().Unix() +} + +// runningCmd contains list of running Go code with [Request.SID] as the +// key. +var runningCmd = runManager{ + sidCommand: make(map[string]*command), +} + +// Format the Go code in the [Request.Body] and return the result to out. +// Any syntax error on the code will be returned as error. +func Format(req Request) (out []byte, err error) { + var logp = `Format` + var fmtbody []byte + + fmtbody, err = imports.Process(`main.go`, []byte(req.Body), nil) + if err != nil { + return nil, fmt.Errorf(`%s: %w`, logp, err) + } + + return fmtbody, nil +} + +// HTTPHandleFormat define the HTTP handler for formating Go code. +func HTTPHandleFormat(httpresw http.ResponseWriter, httpreq *http.Request) { + var ( + logp = `HTTPHandleFormat` + resp = libhttp.EndpointResponse{} + + req Request + rawbody []byte + err error + ) + + httpresw.Header().Set(libhttp.HeaderContentType, libhttp.ContentTypeJSON) + + var contentType = httpreq.Header.Get(libhttp.HeaderContentType) + if contentType != libhttp.ContentTypeJSON { + resp.Code = http.StatusUnsupportedMediaType + resp.Name = `ERR_CONTENT_TYPE` + goto out + } + + rawbody, err = io.ReadAll(httpreq.Body) + if err != nil { + resp.Code = http.StatusInternalServerError + resp.Name = `ERR_INTERNAL` + resp.Message = err.Error() + goto out + } + + err = json.Unmarshal(rawbody, &req) + if err != nil { + resp.Code = http.StatusBadRequest + resp.Name = `ERR_BAD_REQUEST` + resp.Message = err.Error() + goto out + } + + rawbody, err = Format(req) + if err != nil { + resp.Code = http.StatusUnprocessableEntity + resp.Name = `ERR_CODE` + resp.Message = err.Error() + goto out + } + + resp.Code = http.StatusOK + resp.Data = string(rawbody) +out: + rawbody, err = json.Marshal(resp) + if err != nil { + log.Printf(`%s: %s`, logp, err) + resp.Code = http.StatusInternalServerError + } + + httpresw.WriteHeader(resp.Code) + httpresw.Write(rawbody) +} + +// HTTPHandleRun define the HTTP handler for running Go code. +// Each client is identified by unique cookie, so if two Run requests come +// from the same client, the previous Run will be cancelled. +func HTTPHandleRun(httpresw http.ResponseWriter, httpreq *http.Request) { + var ( + logp = `HTTPHandleRun` + + req *Request + resp *libhttp.EndpointResponse + rawb []byte + err error + ) + + httpresw.Header().Set(libhttp.HeaderContentType, libhttp.ContentTypeJSON) + + req, resp = readRequest(httpreq) + if resp != nil { + goto out + } + + rawb, err = Run(req) + if err != nil { + resp = &libhttp.EndpointResponse{ + E: liberrors.E{ + Message: err.Error(), + Name: `ERR_INTERNAL`, + Code: http.StatusInternalServerError, + }, + } + goto out + } + + http.SetCookie(httpresw, req.cookieSid) + resp = &libhttp.EndpointResponse{} + resp.Code = http.StatusOK + resp.Data = string(rawb) +out: + rawb, err = json.Marshal(resp) + if err != nil { + log.Printf(`%s: %s`, logp, err) + resp.Code = http.StatusInternalServerError + } + httpresw.WriteHeader(resp.Code) + httpresw.Write(rawb) +} + +func readRequest(httpreq *http.Request) (req *Request, resp *libhttp.EndpointResponse) { + var contentType = httpreq.Header.Get(libhttp.HeaderContentType) + if contentType != libhttp.ContentTypeJSON { + resp = &libhttp.EndpointResponse{ + E: liberrors.E{ + Message: `invalid content type`, + Name: `ERR_CONTENT_TYPE`, + Code: http.StatusUnsupportedMediaType, + }, + } + return nil, resp + } + + var ( + rawbody []byte + err error + ) + + rawbody, err = io.ReadAll(httpreq.Body) + if err != nil { + resp = &libhttp.EndpointResponse{ + E: liberrors.E{ + Message: err.Error(), + Name: `ERR_INTERNAL`, + Code: http.StatusInternalServerError, + }, + } + return nil, resp + } + + err = json.Unmarshal(rawbody, &req) + if err != nil { + resp = &libhttp.EndpointResponse{ + E: liberrors.E{ + Message: err.Error(), + Name: `ERR_BAD_REQUEST`, + Code: http.StatusBadRequest, + }, + } + return nil, resp + } + + req.cookieSid, err = httpreq.Cookie(cookieNameSid) + if err != nil { + // Ignore the error if cookie is not exist, we wiil generate + // one later. + } + + return req, nil +} + +// Run the Go code in the [Request.Body]. +func Run(req *Request) (out []byte, err error) { + var logp = `Run` + + req.init() + + var cmd *command = runningCmd.get(req.cookieSid.Value) + if cmd != nil { + cmd.ctxCancel() + runningCmd.delete(req.cookieSid.Value) + } + + if len(req.Body) == 0 { + return nil, nil + } + + var userCacheDir string + userCacheDir, err = os.UserCacheDir() + if err != nil { + return nil, fmt.Errorf(`%s: %w`, logp, err) + } + + var tempdir = filepath.Join(userCacheDir, `goplay`, req.cookieSid.Value) + + err = os.MkdirAll(tempdir, 0700) + if err != nil { + return nil, fmt.Errorf(`%s: MkdirAll %q: %w`, logp, tempdir, err) + } + + var gomod = filepath.Join(tempdir, `go.mod`) + + err = os.WriteFile(gomod, []byte(gomodTemplate), 0600) + if err != nil { + return nil, fmt.Errorf(`%s: WriteFile %q: %w`, logp, gomod, err) + } + + var maingo = filepath.Join(tempdir, `main.go`) + + err = os.WriteFile(maingo, []byte(req.Body), 0600) + if err != nil { + return nil, fmt.Errorf(`%s: WriteFile %q: %w`, logp, maingo, err) + } + + cmd, err = newCommand(req, tempdir) + if err != nil { + return nil, fmt.Errorf(`%s: %w`, logp, err) + } + runningCmd.store(req.cookieSid.Value, cmd) + + out = cmd.run() + + return out, nil +} diff --git a/lib/play/play_example_test.go b/lib/play/play_example_test.go new file mode 100644 index 00000000..91f8817b --- /dev/null +++ b/lib/play/play_example_test.go @@ -0,0 +1,151 @@ +// SPDX-FileCopyrightText: 2024 M. Shulhan <ms@kilabit.info> +// +// SPDX-License-Identifier: BSD-3-Clause + +package play + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "net/http/httptest" +) + +func ExampleFormat() { + const codeIndentMissingImport = ` +package main +func main() { + fmt.Println("Hello, world") +} +` + var req = Request{ + Body: codeIndentMissingImport, + } + var ( + out []byte + err error + ) + out, err = Format(req) + if err != nil { + log.Fatal(err) + } + fmt.Printf("%s", out) + + //Output: + //package main + // + //import "fmt" + // + //func main() { + // fmt.Println("Hello, world") + //} +} + +func ExampleHTTPHandleFormat() { + var mux = http.NewServeMux() + mux.HandleFunc(`POST /api/play/format`, HTTPHandleFormat) + + const codeIndentMissingImport = ` +package main +func main() { + fmt.Println("Hello, world") +} +` + var req = Request{ + Body: codeIndentMissingImport, + } + var ( + rawbody []byte + err error + ) + rawbody, err = json.Marshal(&req) + if err != nil { + log.Fatal(err) + } + + var resprec = httptest.NewRecorder() + var httpreq = httptest.NewRequest(`POST`, `/api/play/format`, bytes.NewReader(rawbody)) + httpreq.Header.Set(`Content-Type`, `application/json`) + + mux.ServeHTTP(resprec, httpreq) + var resp = resprec.Result() + + rawbody, err = io.ReadAll(resp.Body) + if err != nil { + log.Fatal(err) + } + + fmt.Printf(`%s`, rawbody) + + //Output: + //{"data":"package main\n\nimport \"fmt\"\n\nfunc main() {\n\tfmt.Println(\"Hello, world\")\n}\n","code":200} +} + +func ExampleHTTPHandleRun() { + var mux = http.NewServeMux() + mux.HandleFunc(`POST /api/play/run`, HTTPHandleRun) + + const codeRun = ` +package main +import "fmt" +func main() { + fmt.Println("Hello, world") +} +` + var req = Request{ + Body: codeRun, + } + var ( + rawbody []byte + err error + ) + rawbody, err = json.Marshal(&req) + if err != nil { + log.Fatal(err) + } + + var resprec = httptest.NewRecorder() + var httpreq = httptest.NewRequest(`POST`, `/api/play/run`, bytes.NewReader(rawbody)) + httpreq.Header.Set(`Content-Type`, `application/json`) + + mux.ServeHTTP(resprec, httpreq) + var resp = resprec.Result() + + rawbody, err = io.ReadAll(resp.Body) + if err != nil { + log.Fatal(err) + } + + fmt.Printf(`%s`, rawbody) + + //Output: + //{"data":"Hello, world\n","code":200} +} + +func ExampleRun() { + const codeRun = ` +package main +import "fmt" +func main() { + fmt.Println("Hello, world") +}` + + var req = Request{ + Body: codeRun, + } + var ( + out []byte + err error + ) + out, err = Run(&req) + if err != nil { + fmt.Printf(`error: %s`, err) + } + fmt.Printf(`%s`, out) + + //Output: + //Hello, world +} diff --git a/lib/play/play_test.go b/lib/play/play_test.go new file mode 100644 index 00000000..4e95fb44 --- /dev/null +++ b/lib/play/play_test.go @@ -0,0 +1,315 @@ +// SPDX-FileCopyrightText: 2024 M. Shulhan <ms@kilabit.info> +// +// SPDX-License-Identifier: BSD-3-Clause + +package play + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "net/http/httputil" + "os" + "strings" + "sync" + "syscall" + "testing" + "time" + + libhttp "git.sr.ht/~shulhan/pakakeh.go/lib/http" + "git.sr.ht/~shulhan/pakakeh.go/lib/test" +) + +func TestMain(m *testing.M) { + now = func() int64 { + return 10_000_000_000 + } + os.Exit(m.Run()) +} + +func TestFormat(t *testing.T) { + var ( + tdata *test.Data + err error + ) + + tdata, err = test.LoadData(`testdata/format_test.txt`) + if err != nil { + t.Fatal(err) + } + + var ( + req Request + name string + exp string + input []byte + got []byte + ) + for name, input = range tdata.Input { + req.Body = string(input) + exp = string(tdata.Output[name]) + + got, err = Format(req) + if err != nil { + test.Assert(t, name, exp, string(got)) + exp = string(tdata.Output[name+`:error`]) + test.Assert(t, name+`:error`, exp, err.Error()) + continue + } + + test.Assert(t, name, exp, string(got)) + } +} + +func TestHTTPHandleFormat(t *testing.T) { + type testCase struct { + tag string + contentType string + } + + var ( + tdata *test.Data + err error + ) + tdata, err = test.LoadData(`testdata/httpHandleFormat_test.txt`) + if err != nil { + t.Fatal(err) + } + + var listCase = []testCase{{ + tag: `invalid_content_type`, + }, { + tag: `no_package`, + contentType: libhttp.ContentTypeJSON, + }, { + tag: `indent_and_missing_import`, + contentType: libhttp.ContentTypeJSON, + }} + + var ( + withBody = true + + req Request + tcase testCase + rawb []byte + body bytes.Buffer + ) + for _, tcase = range listCase { + req.Body = string(tdata.Input[tcase.tag]) + + rawb, err = json.Marshal(&req) + if err != nil { + t.Fatal(err) + } + body.Reset() + body.Write(rawb) + + var req *http.Request = httptest.NewRequest(`POST`, `/`, &body) + req.Header.Set(libhttp.HeaderContentType, tcase.contentType) + + var writer *httptest.ResponseRecorder = httptest.NewRecorder() + + HTTPHandleFormat(writer, req) + + var result *http.Response = writer.Result() + rawb, err = httputil.DumpResponse(result, withBody) + if err != nil { + t.Fatal(err) + } + rawb = bytes.ReplaceAll(rawb, []byte("\r"), []byte("")) + + var exp = string(tdata.Output[tcase.tag]) + test.Assert(t, tcase.tag, exp, string(rawb)) + } +} + +func TestHTTPHandleRun(t *testing.T) { + type testCase struct { + tag string + contentType string + } + + var ( + tdata *test.Data + err error + ) + tdata, err = test.LoadData(`testdata/httpHandleRun_test.txt`) + if err != nil { + t.Fatal(err) + } + + var listCase = []testCase{{ + tag: `no-content-type`, + }, { + tag: `helloworld`, + contentType: libhttp.ContentTypeJSON, + }, { + tag: `nopackage`, + contentType: libhttp.ContentTypeJSON, + }, { + tag: `nopackage`, + contentType: libhttp.ContentTypeJSON, + }} + + var ( + withBody = true + + req Request + tcase testCase + rawb []byte + body bytes.Buffer + ) + for _, tcase = range listCase { + req.Body = string(tdata.Input[tcase.tag]) + + rawb, err = json.Marshal(&req) + if err != nil { + t.Fatal(err) + } + body.Reset() + body.Write(rawb) + + var req *http.Request = httptest.NewRequest(`POST`, `/`, &body) + req.Header.Set(libhttp.HeaderContentType, tcase.contentType) + + var writer *httptest.ResponseRecorder = httptest.NewRecorder() + + HTTPHandleRun(writer, req) + + var result *http.Response = writer.Result() + rawb, err = httputil.DumpResponse(result, withBody) + if err != nil { + t.Fatal(err) + } + rawb = bytes.ReplaceAll(rawb, []byte("\r"), []byte("")) + + var exp = string(tdata.Output[tcase.tag]) + test.Assert(t, tcase.tag, exp, string(rawb)) + } +} + +func TestRun(t *testing.T) { + var ( + tdata *test.Data + err error + ) + + tdata, err = test.LoadData(`testdata/run_test.txt`) + if err != nil { + t.Fatal(err) + } + + var ( + req = &Request{ + cookieSid: &http.Cookie{}, + } + sid string + exp string + input []byte + got []byte + ) + for sid, input = range tdata.Input { + req.cookieSid.Value = sid + req.Body = string(input) + got, err = Run(req) + if err != nil { + exp = string(tdata.Output[sid+`-error`]) + test.Assert(t, sid+`-error`, exp, err.Error()) + } + exp = string(tdata.Output[sid]) + test.Assert(t, sid, exp, string(got)) + } +} + +// TestRunOverlap execute Run multiple times. +// The first Run, run the code with infinite loop. +// The second Run, run normal code. +// On the second Run, the first Run should be cancelled or killed. +func TestRunOverlap(t *testing.T) { + var ( + tdata *test.Data + err error + ) + + tdata, err = test.LoadData(`testdata/run_overlap_test.txt`) + if err != nil { + t.Fatal(err) + } + + // First Run. + var ( + sid = `overlap` + runwg sync.WaitGroup + ) + + runwg.Add(1) + go testRunOverlap(t, &runwg, tdata, `run1`, sid) + time.Sleep(200 * time.Millisecond) + + var cmd1 = runningCmd.get(sid) + if cmd1 == nil { + t.Fatal(`expecting cmd1, got nil`) + } + var cmd1Pid int = <-cmd1.pid + + // Second Run. + + runwg.Add(1) + go testRunOverlap(t, &runwg, tdata, `run2`, sid) + time.Sleep(200 * time.Millisecond) + + // The cmd1 Run should have been killed. + var proc *os.Process + proc, err = os.FindProcess(cmd1Pid) + if err != nil { + t.Fatalf(`find process: %s`, err) + } + + err = proc.Signal(syscall.Signal(0)) + if err != nil { + var exp = os.ErrProcessDone.Error() + test.Assert(t, `signal error`, exp, err.Error()) + } + + runwg.Wait() +} + +func testRunOverlap(t *testing.T, runwg *sync.WaitGroup, tdata *test.Data, + runName, sid string, +) { + // In case the test hang, we found that moving [WaitGroup.Done] to + // the top and call it using defer fix the issue. + defer runwg.Done() + + var ( + req = &Request{ + cookieSid: &http.Cookie{ + Value: sid, + }, + Body: string(tdata.Input[runName]), + } + exp string + out []byte + err error + ) + + out, err = Run(req) + if err != nil { + exp = string(tdata.Output[runName+`-error`]) + test.Assert(t, runName+` error`, exp, err.Error()) + } + + exp = string(tdata.Output[runName+`-output`]) + + // On Inspiron PC, the test run and can be checked using + // [test.Assert]. + // On Yoga laptop, the test output is only "signal: killed" and the + // test hang after [test.Assert], so we replace it with + // [strings.Contains] here. + + if !strings.Contains(string(out), exp) { + t.Errorf("%s output: expecting:\n%s\ngot:\n%s", runName, + exp, string(out)) + } +} diff --git a/lib/play/request.go b/lib/play/request.go new file mode 100644 index 00000000..2b479325 --- /dev/null +++ b/lib/play/request.go @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: 2024 M. Shulhan <ms@kilabit.info> +// +// SPDX-License-Identifier: BSD-3-Clause + +package play + +import ( + "crypto/sha256" + "encoding/hex" + "net/http" + + libbytes "git.sr.ht/~shulhan/pakakeh.go/lib/bytes" +) + +const cookieNameSid = `sid` + +// Request for calling [Format] and [Run]. +type Request struct { + // cookieSid contains unique session ID between request. + // A single session can only run one command at a time, otherwise + // the previous command will be canceled first. + // + // In the HTTP request, the sid is read from cookie named "sid". + cookieSid *http.Cookie + + // Body contains the Go code to be Format-ed or Run. + Body string `json:"body"` +} + +func (req *Request) init() { + if req.cookieSid == nil { + req.cookieSid = &http.Cookie{ + Name: cookieNameSid, + Value: req.generateSid(), + } + } + req.cookieSid.Path = `/` + req.cookieSid.MaxAge = 604800 // Seven days. + req.cookieSid.SameSite = http.SameSiteStrictMode +} + +// generateSid generate session ID from the first 16 hex of SHA256 hash of +// request body plus current epoch in. +func (req *Request) generateSid() string { + var ( + plain = []byte(req.Body) + epoch = now() + ) + plain = libbytes.AppendInt64(plain, epoch) + var cipher = sha256.Sum256(plain) + var dst = make([]byte, hex.EncodedLen(len(cipher))) + hex.Encode(dst, cipher[:]) + + return string(dst[:16]) +} diff --git a/lib/play/run_manager.go b/lib/play/run_manager.go new file mode 100644 index 00000000..61c58053 --- /dev/null +++ b/lib/play/run_manager.go @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: 2024 M. Shulhan <ms@kilabit.info> +// +// SPDX-License-Identifier: BSD-3-Clause + +package play + +import "sync" + +type runManager struct { + sidCommand map[string]*command + sync.Mutex +} + +func (runm *runManager) get(sid string) (cmd *command) { + runm.Lock() + cmd = runm.sidCommand[sid] + runm.Unlock() + return cmd +} + +func (runm *runManager) delete(sid string) { + runm.Lock() + delete(runm.sidCommand, sid) + runm.Unlock() +} + +func (runm *runManager) store(sid string, cmd *command) { + runm.Lock() + runm.sidCommand[sid] = cmd + runm.Unlock() +} diff --git a/lib/play/testdata/format_test.txt b/lib/play/testdata/format_test.txt new file mode 100644 index 00000000..765a9fae --- /dev/null +++ b/lib/play/testdata/format_test.txt @@ -0,0 +1,15 @@ +// SPDX-FileCopyrightText: 2024 M. Shulhan <ms@kilabit.info> +// +// SPDX-License-Identifier: BSD-3-Clause + +>>> no_package +import "fmt" + +func main() { + fmt.Printf(`Hello, world`) +} + +<<< no_package + +<<< no_package:error +Format: main.go:1:1: expected 'package', found 'import' diff --git a/lib/play/testdata/httpHandleFormat_test.txt b/lib/play/testdata/httpHandleFormat_test.txt new file mode 100644 index 00000000..3798c043 --- /dev/null +++ b/lib/play/testdata/httpHandleFormat_test.txt @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: 2024 M. Shulhan <ms@kilabit.info> +// +// SPDX-License-Identifier: BSD-3-Clause + + +>>> invalid_content_type +{} + +<<< invalid_content_type +HTTP/1.1 415 Unsupported Media Type +Connection: close +Content-Type: application/json + +{"name":"ERR_CONTENT_TYPE","code":415} + +>>> no_package +import "fmt" + +func main() { + fmt.Printf(`Hello, world`) +} + +<<< no_package +HTTP/1.1 422 Unprocessable Entity +Connection: close +Content-Type: application/json + +{"message":"Format: main.go:1:1: expected 'package', found 'import'","name":"ERR_CODE","code":422} + +>>> indent_and_missing_import +package main + +func main() { + fmt.Printf(`Hello, world`) +} + +<<< indent_and_missing_import +HTTP/1.1 200 OK +Connection: close +Content-Type: application/json + +{"data":"package main\n\nimport \"fmt\"\n\nfunc main() {\n\tfmt.Printf(`Hello, world`)\n}\n","code":200} diff --git a/lib/play/testdata/httpHandleRun_test.txt b/lib/play/testdata/httpHandleRun_test.txt new file mode 100644 index 00000000..10997d47 --- /dev/null +++ b/lib/play/testdata/httpHandleRun_test.txt @@ -0,0 +1,61 @@ +// SPDX-FileCopyrightText: 2024 M. Shulhan <ms@kilabit.info> +// +// SPDX-License-Identifier: BSD-3-Clause + +>>> no-content-type +package main + +import "fmt" + +func main() { + fmt.Println(`Hello, world`) +} + +<<< no-content-type +HTTP/1.1 415 Unsupported Media Type +Connection: close +Content-Type: application/json + +{"message":"invalid content type","name":"ERR_CONTENT_TYPE","code":415} + +>>> helloworld +package main + +import "fmt" + +func main() { + fmt.Println(`Hello, world`) +} + +<<< helloworld +HTTP/1.1 200 OK +Connection: close +Content-Type: application/json +Set-Cookie: sid=2d4a99a2e766f055; Path=/; Max-Age=604800; SameSite=Strict + +{"data":"Hello, world\n","code":200} + +>>> nopackage +import "fmt" + +func main() { + fmt.Println(`Hello, world`) +} + +<<< nopackage +HTTP/1.1 200 OK +Connection: close +Content-Type: application/json +Set-Cookie: sid=1b892f6da92bf0a3; Path=/; Max-Age=604800; SameSite=Strict + +{"data":"main.go:1:1: expected 'package', found 'import'\n\nexit status 1\n","code":200} + +>>> noimport +package main + +func main() { + fmt.Println(`Hello, world`) +} + +<<< noimport + diff --git a/lib/play/testdata/run_overlap_test.txt b/lib/play/testdata/run_overlap_test.txt new file mode 100644 index 00000000..001530e4 --- /dev/null +++ b/lib/play/testdata/run_overlap_test.txt @@ -0,0 +1,56 @@ +// SPDX-FileCopyrightText: 2024 M. Shulhan <ms@kilabit.info> +// +// SPDX-License-Identifier: BSD-3-Clause + +>>> run1 +package main + +import ( + "fmt" + "log" + "os" + "os/signal" + "time" +) + +func main() { + var ( + sigq = make(chan os.Signal, 1) + x uint + s os.Signal + ) + signal.Notify(sigq, os.Interrupt, os.Kill) + for { + select { + case s = <-sigq: + log.Println("STOPPED by signal:", s) + return + default: + fmt.Println(x) + time.Sleep(50 * time.Millisecond) + x++ + } + } +} + +<<< run1-error +Run: signal: killed + +<<< run1-output +signal: killed + + +>>> run2 +package main + +import "fmt" + +func main() { + fmt.Println("Hello, Go!") +} + +<<< run2-error + +<<< run2-output +Hello, Go! + diff --git a/lib/play/testdata/run_test.txt b/lib/play/testdata/run_test.txt new file mode 100644 index 00000000..0154920c --- /dev/null +++ b/lib/play/testdata/run_test.txt @@ -0,0 +1,35 @@ +// SPDX-FileCopyrightText: 2024 M. Shulhan <ms@kilabit.info> +// +// SPDX-License-Identifier: BSD-3-Clause + +>>> nopackage +import "fmt" + +func main() { + fmt.Println(`Hello, world`) +} + +<<< nopackage-error +Run: exit status 1 + +<<< nopackage +main.go:1:1: expected 'package', found 'import' + +exit status 1 + +>>> noimport +package main + +func main() { + fmt.Println(`Hello, world`) +} + +<<< noimport-error +Run: exit status 1 + +<<< noimport +# play.local +./main.go:4:2: undefined: fmt + +exit status 1 + |
