diff --git a/api.go b/api.go index c0b3669..b460c47 100644 --- a/api.go +++ b/api.go @@ -106,6 +106,8 @@ Check out the {blue}{bold}guide{reset} command to get started. util.Document(L, hshcomp, "Completions interface for Hilbish.") L.SetField(mod, "completion", hshcomp) + jobs = newJobHandler() + L.Push(mod) return 1 diff --git a/docs/hooks/job.txt b/docs/hooks/job.txt new file mode 100644 index 0000000..47c41ca --- /dev/null +++ b/docs/hooks/job.txt @@ -0,0 +1,12 @@ +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 +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. + ++ `job.start` -> job > Thrown when a new background job starts. + ++ `job.done` -> job > Thrown when a background jobs exits. + diff --git a/exec.go b/exec.go index efc92ab..6818410 100644 --- a/exec.go +++ b/exec.go @@ -1,13 +1,16 @@ package main import ( + "bytes" "context" "errors" + "os/exec" "fmt" "os" "path/filepath" "runtime" "strings" + "syscall" "time" "hilbish/util" @@ -17,6 +20,7 @@ import ( //"github.com/yuin/gopher-lua/parse" "mvdan.cc/sh/v3/interp" "mvdan.cc/sh/v3/syntax" + "mvdan.cc/sh/v3/expand" ) var errNotExec = errors.New("not executable") @@ -92,6 +96,7 @@ func execCommand(cmd, old string) error { return err } + var bg bool exechandle := func(ctx context.Context, args []string) error { _, argstring := splitInput(strings.Join(args, " ")) // i dont really like this but it works @@ -150,15 +155,138 @@ func execCommand(cmd, old string) error { return interp.NewExitStatus(127) } - return interp.DefaultExecHandler(2 * time.Second)(ctx, args) + killTimeout := 2 * time.Second + // from here is basically copy-paste of the default exec handler from + // sh/interp but with our job handling + hc := interp.HandlerCtx(ctx) + path, err := interp.LookPathDir(hc.Dir, hc.Env, args[0]) + if err != nil { + fmt.Fprintln(hc.Stderr, err) + return interp.NewExitStatus(127) + } + + env := hc.Env + envList := make([]string, 0, 64) + env.Each(func(name string, vr expand.Variable) bool { + if !vr.IsSet() { + // If a variable is set globally but unset in the + // runner, we need to ensure it's not part of the final + // list. Seems like zeroing the element is enough. + // This is a linear search, but this scenario should be + // rare, and the number of variables shouldn't be large. + for i, kv := range envList { + if strings.HasPrefix(kv, name+"=") { + envList[i] = "" + } + } + } + if vr.Exported && vr.Kind == expand.String { + envList = append(envList, name+"="+vr.String()) + } + return true + }) + cmd := exec.Cmd{ + Path: path, + Args: args, + Env: envList, + Dir: hc.Dir, + Stdin: hc.Stdin, + Stdout: hc.Stdout, + Stderr: hc.Stderr, + } + + err = cmd.Start() + job := jobs.getLatest() + if err == nil { + if bg { + job.start(cmd.Process.Pid) + } + + if done := ctx.Done(); done != nil { + go func() { + <-done + + if killTimeout <= 0 || runtime.GOOS == "windows" { + cmd.Process.Signal(os.Kill) + return + } + + // TODO: don't temporarily leak this goroutine + // if the program stops itself with the + // interrupt. + go func() { + time.Sleep(killTimeout) + cmd.Process.Signal(os.Kill) + }() + cmd.Process.Signal(os.Interrupt) + }() + } + + 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: + if bg { + job.exitCode = int(exit) + job.finish() + } + return interp.NewExitStatus(exit) } + runner, _ := interp.New( interp.StdIO(os.Stdin, os.Stdout, os.Stderr), interp.ExecHandler(exechandle), ) - err = runner.Run(context.TODO(), file) - return err + buf := new(bytes.Buffer) + printer := syntax.NewPrinter() + + for _, stmt := range file.Stmts { + bg = false + if stmt.Background { + bg = true + printer.Print(buf, stmt.Cmd) + + stmtStr := buf.String() + buf.Reset() + jobs.add(stmtStr) + } + + err = runner.Run(context.TODO(), stmt) + if err != nil { + return err + } + } + + return nil } func lookpath(file string) error { // custom lookpath function so we know if a command is found *and* is executable diff --git a/job.go b/job.go new file mode 100644 index 0000000..2764fc8 --- /dev/null +++ b/job.go @@ -0,0 +1,75 @@ +package main + +import ( + "sync" + + "github.com/yuin/gopher-lua" +) + +var jobs *jobHandler + +type job struct { + cmd string + running bool + id int + pid int + exitCode int +} + +func (j *job) start(pid int) { + j.pid = pid + j.running = true + hooks.Em.Emit("job.start", j.lua()) +} + +func (j *job) finish() { + j.running = false + hooks.Em.Emit("job.done", j.lua()) +} + +func (j *job) lua() *lua.LTable { + // returns lua table for job + // because userdata is gross + luaJob := l.NewTable() + + l.SetField(luaJob, "cmd", lua.LString(j.cmd)) + l.SetField(luaJob, "running", lua.LBool(j.running)) + l.SetField(luaJob, "id", lua.LNumber(j.id)) + l.SetField(luaJob, "pid", lua.LNumber(j.pid)) + l.SetField(luaJob, "exitCode", lua.LNumber(j.exitCode)) + + return luaJob +} + +type jobHandler struct { + jobs map[int]*job + latestID int + mu *sync.RWMutex +} + +func newJobHandler() *jobHandler { + return &jobHandler{ + jobs: make(map[int]*job), + latestID: 0, + mu: &sync.RWMutex{}, + } +} + +func (j *jobHandler) add(cmd string) { + j.mu.Lock() + defer j.mu.Unlock() + + j.latestID++ + j.jobs[j.latestID] = &job{ + cmd: cmd, + running: false, + id: j.latestID, + } +} + +func (j *jobHandler) getLatest() *job { + j.mu.RLock() + defer j.mu.RUnlock() + + return j.jobs[j.latestID] +}