From d2f16dfbbf51f3c203cf9f70e8cc277a05fa112f Mon Sep 17 00:00:00 2001 From: sammyette <38820196+TorchedSammy@users.noreply.github.com> Date: Sat, 21 May 2022 20:53:36 -0400 Subject: [PATCH] feat: job enhancements (#153) * feat: add hilbish.job.add function this is mainly to accomodate for the employer handler (#152) * feat!: add start function to jobs the commit itself adds a few things but the main purpose is to facilitate a lua side start function that can restart the job there is a breaking change in the hilbish.job.add function; it is now required to provide an extra table for arguments, since the first cmd table isnt really what's actually ran * fix: reuse standard files for jobs * fix: deadlock in lua job add function and not taking proper amount of args * fix: assign binary path to job * feat: emit job.add hook when job is added * chore: update modules * fix: use setpgid on cmd procattr for background jobs * fix: use right procattr on correct os * fix: set bg proc attr in build tagged file * feat: add disown function * fix: stop jobs on exit * feat: add disown command * feat: add jobs.last function to get last job * feat: make disown command get last job if id isnt suppied as arg * chore: remove unused code * feat: add job output * chore: fix comments * fix!: make exec path in job add explicit in lua side * docs: add docs and changelogs relating to jobs --- CHANGELOG.md | 13 +++ docs/hooks/job.txt | 10 +- docs/jobs.txt | 40 ++++++++ exec.go | 70 +++++++------- execfile_unix.go | 5 + execfile_windows.go | 5 + go.mod | 2 +- go.sum | 2 + job.go | 187 ++++++++++++++++++++++++++++++++++--- main.go | 17 +++- nature/commands/disown.lua | 25 +++++ nature/commands/init.lua | 1 + 12 files changed, 317 insertions(+), 60 deletions(-) create mode 100644 docs/jobs.txt create mode 100644 nature/commands/disown.lua diff --git a/CHANGELOG.md b/CHANGELOG.md index f350cff..f2f7850 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,17 @@ is for everything/anything as opposed to just adding a single command completion [#122](https://github.com/Rosettea/Hilbish/issues/122) - `fs.abs(path)` to get absolute path. - Nature module (`doc nature`) +- `hilbish.jobs.add(cmdstr, args, execPath)` to add a job to the job table. +`cmdstr` would be user input, `args` is the args for the command (includes arg0) +and `execPath` is absolute path to command executable +- `job.add` hook is thrown when a job is added. acts as a unique hook for +jobs +- `hilbish.jobs.disown(id)` and `disown` builtin to disown a job. `disown` +without arguments will disown the last job. +- `hilbish.jobs.last()` returns the last added job. +- Job output (stdout/stderr) can now be obtained via the `stdout` and `stderr` +fields on a job object. +- Documentation for jobs is now available via `doc jobs`. ### Changed - **Breaking Change:** Upgraded to Lua 5.4. @@ -57,6 +68,8 @@ certain color rules. - Cursor position with CJK characters. ([#145](https://github.com/Rosettea/Hilbish/pull/145)) - Files with same name as parent folder in completions getting cut off [#136](https://github.com/Rosettea/Hilbish/issues/136)) - `hilbish.which` now works with commanders and aliases. +- Background jobs no longer take stdin so they do not interfere with shell +input. ## [1.2.0] - 2022-03-17 ### Added diff --git a/docs/hooks/job.txt b/docs/hooks/job.txt index 497df1c..cbef74f 100644 --- a/docs/hooks/job.txt +++ b/docs/hooks/job.txt @@ -1,11 +1,5 @@ -Note: A `job` is a table with the following keys: -- cmd: command string -- running: boolean whether the job is running -- id: unique id for the job -- pid: process id for the job -- exitCode: exit code of the job -In ordinary cases you'd prefer to use the id instead of pid. The id is unique to -Hilbish and is how you get jobs with the `hilbish.jobs` interface. +Note: `job` refers to a job object. YOu can check `doc jobs` for more +detail. + `job.start` -> job > Thrown when a new background job starts. diff --git a/docs/jobs.txt b/docs/jobs.txt new file mode 100644 index 0000000..0074931 --- /dev/null +++ b/docs/jobs.txt @@ -0,0 +1,40 @@ +Hilbish has pretty standard job control. It's missing one or two things, +but works well. One thing which is different from other shells +(besides Hilbish) itself is the API for jobs, and of course it's in Lua. +You can add jobs, stop and delete (disown) them and even get output. + +# Job Interface +The job interface refers to `hilbish.jobs`. +## Functions +(Note that in the list here, they're called from `hilbish.jobs`, so +a listing of `foo` would mean `hilbish.jobs.foo`) + +- `all()` -> {jobs}: Returns a table of all jobs. +- `last()` -> job: Returns the last added job. +- `get(id)` -> job: Get a job by its ID. +- `add(cmdstr, args, execPath)` -> job: Adds a new job to the job table. +Note that this does not run the command; You have to start it manually. +`cmdstr` is the user's input for the job, `args` is a table of arguments +for the command. It includes arg0 (don't set it as entry 0 in the table) +and `execPath` is an absolute path for the command executable. +- `disown(id)`: Removes a job by ID from the job table. + +# Job Object +A job object on the Lua side is a table with some functions. +On the under side it represents a job in the job table. +You can still have a job object for a disowned job, +it just won't be *working* anywhere. :^) + +## Properties +- `cmd`: command string +- `running`: boolean whether the job is running +- `id`: unique id for the job +- `pid`: process id for the job +- `exitCode`: exit code of the job +In ordinary cases you'd prefer to use the `id` instead of `pid`. +The `id` is unique to Hilbish and is how you get jobs with the +`hilbish.jobs` interface. It may also not describe the job entirely. + +## Functions +- `stop()`: Stops the job. +- `start()`: Starts the job. diff --git a/exec.go b/exec.go index 3c41d5f..2e0e0b1 100644 --- a/exec.go +++ b/exec.go @@ -239,7 +239,7 @@ func execCommand(cmd string, terminalOut bool) (io.Writer, io.Writer, error) { stmtStr := buf.String() buf.Reset() - jobs.add(stmtStr) + jobs.add(stmtStr, []string{}, "") } interp.ExecHandler(execHandle(bg))(runner) @@ -357,13 +357,15 @@ func execHandle(bg bool) interp.ExecHandlerFunc { Stderr: hc.Stderr, } - err = cmd.Start() var j *job if bg { j = jobs.getLatest() - j.setHandle(cmd.Process) - j.start(cmd.Process.Pid) + j.setHandle(&cmd) + err = j.start() + } else { + err = cmd.Start() } + if err == nil { if done := ctx.Done(); done != nil { go func() { @@ -388,35 +390,8 @@ func execHandle(bg bool) interp.ExecHandlerFunc { err = cmd.Wait() } - var exit uint8 - switch x := err.(type) { - case *exec.ExitError: - // started, but errored - default to 1 if OS - // doesn't have exit statuses - if status, ok := x.Sys().(syscall.WaitStatus); ok { - if status.Signaled() { - if ctx.Err() != nil { - return ctx.Err() - } - exit = uint8(128 + status.Signal()) - goto end - } - exit = uint8(status.ExitStatus()) - goto end - } - exit = 1 - goto end - case *exec.Error: - // did not start - fmt.Fprintf(hc.Stderr, "%v\n", err) - exit = 127 - goto end - case nil: - goto end - default: - return err - } - end: + exit := handleExecErr(err) + if bg { j.exitCode = int(exit) j.finish() @@ -425,6 +400,35 @@ func execHandle(bg bool) interp.ExecHandlerFunc { } } +func handleExecErr(err error) (exit uint8) { + ctx := context.TODO() + + switch x := err.(type) { + case *exec.ExitError: + // started, but errored - default to 1 if OS + // doesn't have exit statuses + if status, ok := x.Sys().(syscall.WaitStatus); ok { + if status.Signaled() { + if ctx.Err() != nil { + return + } + exit = uint8(128 + status.Signal()) + return + } + exit = uint8(status.ExitStatus()) + return + } + exit = 1 + return + case *exec.Error: + // did not start + //fmt.Fprintf(hc.Stderr, "%v\n", err) + exit = 127 + default: return + } + + return +} func lookpath(file string) error { // custom lookpath function so we know if a command is found *and* is executable var skip []string if runtime.GOOS == "windows" { diff --git a/execfile_unix.go b/execfile_unix.go index 3160b85..44f924a 100644 --- a/execfile_unix.go +++ b/execfile_unix.go @@ -4,8 +4,13 @@ package main import ( "os" + "syscall" ) +var bgProcAttr *syscall.SysProcAttr = &syscall.SysProcAttr{ + Setpgid: true, +} + func findExecutable(path string, inPath, dirs bool) error { f, err := os.Stat(path) if err != nil { diff --git a/execfile_windows.go b/execfile_windows.go index 502a595..4b3feef 100644 --- a/execfile_windows.go +++ b/execfile_windows.go @@ -5,8 +5,13 @@ package main import ( "path/filepath" "os" + "syscall" ) +var bgProcAttr *syscall.SysProcAttr = &syscall.SysProcAttr{ + CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP, +} + func findExecutable(path string, inPath, dirs bool) error { nameExt := filepath.Ext(path) pathExts := filepath.SplitList(os.Getenv("PATHEXT")) diff --git a/go.mod b/go.mod index e2c9f10..6974c6b 100644 --- a/go.mod +++ b/go.mod @@ -29,4 +29,4 @@ replace github.com/maxlandon/readline => ./readline replace layeh.com/gopher-luar => github.com/layeh/gopher-luar v1.0.10 -replace github.com/arnodel/golua => github.com/Rosettea/golua v0.0.0-20220419183026-6d22d6fec5ac +replace github.com/arnodel/golua => github.com/Rosettea/golua v0.0.0-20220518005949-116371948fe3 diff --git a/go.sum b/go.sum index bc3a192..c81fd1e 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ github.com/Rosettea/golua v0.0.0-20220419183026-6d22d6fec5ac h1:dtXrgjch8PQyf7C90anZUquB5U3dr8AcMGJofeuirrI= github.com/Rosettea/golua v0.0.0-20220419183026-6d22d6fec5ac/go.mod h1:9jzpYPiU2is0HVGCiuIOBSXdergHUW44IEjmuN1UrIE= +github.com/Rosettea/golua v0.0.0-20220518005949-116371948fe3 h1:I/wWr40FFLFF9pbT3wLb1FAEZhKb/hUWE+nJ5uHBK2g= +github.com/Rosettea/golua v0.0.0-20220518005949-116371948fe3/go.mod h1:9jzpYPiU2is0HVGCiuIOBSXdergHUW44IEjmuN1UrIE= github.com/Rosettea/sh/v3 v3.4.0-0.dev.0.20220306140409-795a84b00b4e h1:P2XupP8SaylWaudD1DqbWtZ3mIa8OsE9635LmR+Q+lg= github.com/Rosettea/sh/v3 v3.4.0-0.dev.0.20220306140409-795a84b00b4e/go.mod h1:R09vh/04ILvP2Gj8/Z9Jd0Dh0ZIvaucowMEs6abQpWs= github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d h1:licZJFw2RwpHMqeKTCYkitsPqHNxTmd4SNR5r94FGM8= diff --git a/job.go b/job.go index d20666b..a0bdcb5 100644 --- a/job.go +++ b/job.go @@ -1,8 +1,13 @@ package main import ( - "sync" + "bytes" + "errors" + "io" "os" + "os/exec" + "sync" + "syscall" "hilbish/util" @@ -17,18 +22,59 @@ type job struct { id int pid int exitCode int - proc *os.Process + once bool + args []string + // save path for a few reasons, one being security (lmao) while the other + // would just be so itll be the same binary command always (path changes) + path string + handle *exec.Cmd + cmdout io.Writer + cmderr io.Writer + stdout *bytes.Buffer + stderr *bytes.Buffer } -func (j *job) start(pid int) { - j.pid = pid +func (j *job) start() error { + if j.handle == nil || j.once { + // cmd cant be reused so make a new one + cmd := exec.Cmd{ + Path: j.path, + Args: j.args, + } + j.setHandle(&cmd) + } + // bgProcAttr is defined in execfile_.go, it holds a procattr struct + // in a simple explanation, it makes signals from hilbish (sigint) + // not go to it (child process) + j.handle.SysProcAttr = bgProcAttr + // reset output buffers + j.stdout.Reset() + j.stderr.Reset() + // make cmd write to both standard output and output buffers for lua access + j.handle.Stdout = io.MultiWriter(j.cmdout, j.stdout) + j.handle.Stderr = io.MultiWriter(j.cmderr, j.stderr) + + if !j.once { + j.once = true + } + + err := j.handle.Start() + proc := j.getProc() + + j.pid = proc.Pid j.running = true + hooks.Em.Emit("job.start", j.lua()) + + return err } func (j *job) stop() { // finish will be called in exec handle - j.proc.Kill() + proc := j.getProc() + if proc != nil { + proc.Kill() + } } func (j *job) finish() { @@ -36,13 +82,35 @@ func (j *job) finish() { hooks.Em.Emit("job.done", j.lua()) } -func (j *job) setHandle(handle *os.Process) { - j.proc = handle +func (j *job) wait() { + j.handle.Wait() +} + +func (j *job) setHandle(handle *exec.Cmd) { + j.handle = handle + j.args = handle.Args + j.path = handle.Path + if handle.Stdout != nil { + j.cmdout = handle.Stdout + } + if handle.Stderr != nil { + j.cmderr = handle.Stderr + } +} + +func (j *job) getProc() *os.Process { + handle := j.handle + if handle != nil { + return handle.Process + } + + return nil } func (j *job) lua() rt.Value { jobFuncs := map[string]util.LuaExport{ "stop": {j.luaStop, 0, false}, + "start": {j.luaStart, 0, false}, } luaJob := rt.NewTable() util.SetExports(l, luaJob, jobFuncs) @@ -52,10 +120,23 @@ func (j *job) lua() rt.Value { luaJob.Set(rt.StringValue("id"), rt.IntValue(int64(j.id))) luaJob.Set(rt.StringValue("pid"), rt.IntValue(int64(j.pid))) luaJob.Set(rt.StringValue("exitCode"), rt.IntValue(int64(j.exitCode))) + luaJob.Set(rt.StringValue("stdout"), rt.StringValue(string(j.stdout.Bytes()))) + luaJob.Set(rt.StringValue("stderr"), rt.StringValue(string(j.stderr.Bytes()))) return rt.TableValue(luaJob) } +func (j *job) luaStart(t *rt.Thread, c *rt.GoCont) (rt.Cont, error) { + if !j.running { + err := j.start() + exit := handleExecErr(err) + j.exitCode = int(exit) + j.finish() + } + + return c.Next(), nil +} + func (j *job) luaStop(t *rt.Thread, c *rt.GoCont) (rt.Cont, error) { if j.running { j.stop() @@ -79,7 +160,7 @@ func newJobHandler() *jobHandler { } } -func (j *jobHandler) add(cmd string) *job { +func (j *jobHandler) add(cmd string, args []string, path string) *job { j.mu.Lock() defer j.mu.Unlock() @@ -88,8 +169,15 @@ func (j *jobHandler) add(cmd string) *job { cmd: cmd, running: false, id: j.latestID, + args: args, + path: path, + cmdout: os.Stdout, + cmderr: os.Stderr, + stdout: &bytes.Buffer{}, + stderr: &bytes.Buffer{}, } j.jobs[j.latestID] = jb + hooks.Em.Emit("job.add", jb.lua()) return jb } @@ -101,11 +189,41 @@ func (j *jobHandler) getLatest() *job { return j.jobs[j.latestID] } +func (j *jobHandler) disown(id int) error { + j.mu.RLock() + if j.jobs[id] == nil { + return errors.New("job doesnt exist") + } + j.mu.RUnlock() + + j.mu.Lock() + delete(j.jobs, id) + j.mu.Unlock() + + return nil +} + +func (j *jobHandler) stopAll() { + j.mu.RLock() + defer j.mu.RUnlock() + + for _, jb := range j.jobs { + // on exit, unix shell should send sighup to all jobs + if jb.running { + proc := jb.getProc() + proc.Signal(syscall.SIGHUP) + jb.wait() // waits for program to exit due to sighup + } + } +} + func (j *jobHandler) loader(rtm *rt.Runtime) *rt.Table { jobFuncs := map[string]util.LuaExport{ "all": {j.luaAllJobs, 0, false}, + "last": {j.luaLastJob, 0, false}, "get": {j.luaGetJob, 1, false}, - "add": {j.luaAddJob, 1, false}, + "add": {j.luaAddJob, 3, false}, + "disown": {j.luaDisownJob, 1, false}, } luaJob := rt.NewTable() @@ -135,18 +253,30 @@ func (j *jobHandler) luaGetJob(t *rt.Thread, c *rt.GoCont) (rt.Cont, error) { } func (j *jobHandler) luaAddJob(t *rt.Thread, c *rt.GoCont) (rt.Cont, error) { - j.mu.RLock() - defer j.mu.RUnlock() - - if err := c.Check1Arg(); err != nil { + if err := c.CheckNArgs(3); err != nil { return nil, err } cmd, err := c.StringArg(0) if err != nil { return nil, err } + largs, err := c.TableArg(1) + if err != nil { + return nil, err + } + execPath, err := c.StringArg(2) + if err != nil { + return nil, err + } - jb := j.add(cmd) + var args []string + util.ForEach(largs, func(k rt.Value, v rt.Value) { + if v.Type() == rt.StringType { + args = append(args, v.AsString()) + } + }) + + jb := j.add(cmd, args, execPath) return c.PushingNext1(t.Runtime, jb.lua()), nil } @@ -162,3 +292,32 @@ func (j *jobHandler) luaAllJobs(t *rt.Thread, c *rt.GoCont) (rt.Cont, error) { return c.PushingNext1(t.Runtime, rt.TableValue(jobTbl)), nil } + +func (j *jobHandler) luaDisownJob(t *rt.Thread, c *rt.GoCont) (rt.Cont, error) { + if err := c.Check1Arg(); err != nil { + return nil, err + } + jobID, err := c.IntArg(0) + if err != nil { + return nil, err + } + + err = j.disown(int(jobID)) + if err != nil { + return nil, err + } + + return c.Next(), nil +} + +func (j *jobHandler) luaLastJob(t *rt.Thread, c *rt.GoCont) (rt.Cont, error) { + j.mu.RLock() + defer j.mu.RUnlock() + + job := j.jobs[j.latestID] + if job == nil { // incase we dont have any jobs yet + return c.Next(), nil + } + + return c.PushingNext1(t.Runtime, job.lua()), nil +} diff --git a/main.go b/main.go index 6fcb7a7..32484ac 100644 --- a/main.go +++ b/main.go @@ -221,6 +221,8 @@ input: } fmt.Printf("\u001b[7m∆\u001b[0m" + strings.Repeat(" ", termwidth - 1) + "\r") } + + exit(0) } func continuePrompt(prev string) (string, error) { @@ -294,12 +296,19 @@ func contains(s []string, e string) bool { } func exit(code int) { - // wait for all timers to finish before exiting - for { - if timers.running == 0 { - os.Exit(code) + jobs.stopAll() + + // wait for all timers to finish before exiting. + // only do that when not interactive + if !interactive { + for { + if timers.running == 0 { + os.Exit(code) + } } } + + os.Exit(code) } func getVersion() string { diff --git a/nature/commands/disown.lua b/nature/commands/disown.lua new file mode 100644 index 0000000..f8f144f --- /dev/null +++ b/nature/commands/disown.lua @@ -0,0 +1,25 @@ +local commander = require 'commander' + +commander.register('disown', function(args) + if #hilbish.jobs.all() == 0 then + print 'disown: no current job' + return 1 + end + + local id + if #args < 0 then + id = tonumber(args[1]) + if not id then + print 'disown: invalid id for job' + return 1 + end + else + id = hilbish.jobs.last().id + end + + local ok = pcall(hilbish.jobs.disown, id) + if not ok then + print 'disown: job does not exist' + return 2 + end +end) diff --git a/nature/commands/init.lua b/nature/commands/init.lua index e824c7c..589cbd5 100644 --- a/nature/commands/init.lua +++ b/nature/commands/init.lua @@ -4,3 +4,4 @@ require 'nature.commands.cdr' require 'nature.commands.doc' require 'nature.commands.exit' require 'nature.commands.guide' +require 'nature.commands.disown'