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
fg-job
sammyette 2022-05-21 20:53:36 -04:00 committed by GitHub
parent 392cb66382
commit d2f16dfbbf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 317 additions and 60 deletions

View File

@ -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

View File

@ -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.

40
docs/jobs.txt 100644
View File

@ -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.

70
exec.go
View File

@ -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" {

View File

@ -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 {

View File

@ -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"))

2
go.mod
View File

@ -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

2
go.sum
View File

@ -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=

187
job.go
View File

@ -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_<os>.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
}

17
main.go
View File

@ -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 {

View File

@ -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)

View File

@ -4,3 +4,4 @@ require 'nature.commands.cdr'
require 'nature.commands.doc'
require 'nature.commands.exit'
require 'nature.commands.guide'
require 'nature.commands.disown'