/* SHS: Syntactically Homogeneous Shell * Copyright (C) 2019 Aidan Hahn * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package stdlib import ( "os" "io" "fmt" "bytes" "os/exec" "context" "syscall" "strconv" "os/signal" "golang.org/x/sys/unix" "gitlab.com/whom/shs/ast" "gitlab.com/whom/shs/log" ) var sigChan chan os.Signal var waitChan chan error /* mapping of pid to Proc */ var JobMap = map[int]Proc{} /* id of shell process group */ var pgid, pid int /* cancel func for current running process */ var CurCancel *context.CancelFunc /* Holds an os/exec Cmd object and its context cancel function */ type Proc struct { Ctl *exec.Cmd Cancel context.CancelFunc } var catchMe = []os.Signal{ syscall.SIGINT, } /* Exit code of last run process */ var LastExitCode int // Job control handlers func signalHandler() { for { <- sigChan log.Log(log.DEBUG, "caught SIGINT", "jobctl") if CurCancel != nil { (*CurCancel)() } } } // for some reason not implemented in stdlib func tcsetpgrp(fd int, pgrp int) error { return unix.IoctlSetPointerInt(fd, unix.TIOCSPGRP, pgrp) } // wrapper for convenience func setSigState() { signal.Ignore(syscall.SIGTTOU, syscall.SIGTTIN, syscall.SIGTSTP) signal.Notify(sigChan, syscall.SIGINT, syscall.SIGQUIT) } /* Sets pgid * Installs a sigchld handler * returns true if success, false on error */ func InitShellFeatures() bool { // TODO: adjust capacity, make configurable maybe? sigChan = make(chan os.Signal, 5) waitChan = make(chan error, 5) go signalHandler() setSigState() pid = os.Getpid() var errr error pgid, errr = syscall.Getpgid(pid) if errr != nil { log.Log(log.ERR, "Failure to get pgid: " + errr.Error(), "jobctl") return false } termPgrp, err := unix.IoctlGetInt(0, unix.TIOCGPGRP) if err != nil { log.Log(log.ERR, "Failure to get pgrp: " + err.Error(), "jobctl") return false } if pgid != termPgrp { syscall.Kill(-termPgrp, unix.SIGTTIN) } syscall.Setpgid(0, 0) tcsetpgrp(0, pid) return true } /* Tears down session */ func TeardownShell() { close(sigChan) close(waitChan) for k := range JobMap { JobMap[k].Cancel() } } /* Check background processes states * If any have finished, reap them */ func CheckBGProcs() { for key, proc := range JobMap { maybeFilePath := fmt.Sprintf("/proc/%d", key) _, err := os.Stat(maybeFilePath) if os.IsNotExist(err) { if err := proc.Ctl.Process.Release(); err != nil { log.Log(log.ERR, fmt.Sprintf("Failed to release exited process %d: %s", key, err.Error()), "jobctl") } else { // not actually an error, just abusing default visibility log.Log(log.ERR, fmt.Sprintf("Child [%d] exited", key), "jobctl") delete(JobMap, key) } } else if err != nil { log.Log(log.DEBUG, "Error checking if process exists in proc: " + err.Error(), "jobctl") } } } /* Makes and stores a new process in the job control * uses os/exec Cmd object. sets SysProcAttr.Foreground * calls Cmd.Start() */ func LaunchProcess( path string, args []string, background bool, stdin io.Reader, stdout io.Writer, stderr io.Writer ) { c, cancel := context.WithCancel(context.Background()) cmd := exec.CommandContext(c, path, args...) cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} if !background { cmd.SysProcAttr.Foreground = true } if stdin == nil { stdin = os.Stdin } if stdout == nil { stdout = os.Stdout } if stderr == nil { stderr = os.Stderr } cmd.Stdin = stdin cmd.Stdout = stdout cmd.Stderr = stderr cmd.Start() cpid := cmd.Process.Pid // TODO: check for clobber? // unlikely, but PIDs do reset after a while JobMap[cpid] = Proc{ Ctl: cmd, Cancel: cancel } CurCancel = &cancel if !background { cmd.Wait() tcsetpgrp(0, pgid) delete(JobMap, cpid) CurCancel = nil } } /* Takes n arguments (list of tokens generated by lexing a shell command) * Evaluates arguments, but does not err on undefined symbols (note the last arg to Eval(...)) * Executes shell command and returns nil * * Example (l vim file.txt) */ func Call(in *ast.Token, vt ast.VarTable, ft ast.FuncTable) *ast.Token { if in == nil { log.Log(log.ERR, "no arguments given", "call") return nil } if in.Tag == ast.LIST { log.Log(log.ERR, "couldnt exec, target bin is a list", "call") return nil } path, err := exec.LookPath(in.Value()) if err != nil { log.Log(log.ERR, "Couldnt exec " + in.Value() + ", file not found", "call") return nil } args := []string{} for i := in.Next; i != nil; i = i.Next { if i.Tag == ast.LIST { log.Log(log.ERR, "Couldnt exec " + path + ", element in arguments is a list", "call") return nil } args = append(args, i.Value()) } LaunchProcess(path, args, false, nil, nil, nil) return nil } /* Starts a call in the background * Takes n args (a shell command not delimited by string delimiters) * * Example: (bg vim file.txt) */ func Bgcall(in *ast.Token, vt ast.VarTable, ft ast.FuncTable) *ast.Token { if in.Tag == ast.LIST { log.Log(log.ERR, "couldnt exec, target bin is a list", "call") return nil } path, err := exec.LookPath(in.Value()) if err != nil { log.Log(log.ERR, "Couldnt exec " + in.Value() + ", file not found", "call") return nil } args := []string{} for i := in.Next; i != nil; i = i.Next { if i.Tag == ast.LIST { log.Log(log.ERR, "Couldnt exec " + path + ", element in arguments is a list", "call") return nil } args = append(args, i.Value()) } LaunchProcess(path, args, true, nil, nil, nil) return nil } /* takes one argument: pid of process to foreground * returns nil * * Example: * (bg vim file.txt) * ( ) * (fg ) */ func Fg(in *ast.Token, vt ast.VarTable, ft ast.FuncTable) *ast.Token { if len(JobMap) < 1 { return nil } pid, err := strconv.ParseFloat(in.Value(), 64) if err != nil { log.Log(log.ERR, "value supplied to fg could not be cast to float", "fg") return nil } ipid := int(pid) proc, ok := JobMap[ipid] if !ok { log.Log(log.ERR, "Process not found, was it started by this shell?", "fg") return nil } if err := tcsetpgrp(0, ipid); err != nil { log.Log(log.ERR, "Error foregrounding process: " + err.Error(), "fg") return nil } proc.Ctl.Wait() return nil } /* Takes 0 args * returns a list of PIDs * * Example: * (bg ping google.com) * (bg .........) * (jobs) */ func Jobs(in *ast.Token, vt ast.VarTable, fg ast.FuncTable) *ast.Token { ret := &ast.Token{ Tag: ast.LIST, } var _inner *ast.Token iter := &_inner for k := range JobMap { (*iter) = &ast.Token{ Tag: ast.STRING, } (*iter).Set(fmt.Sprintf("%d", k)) iter = &(*iter).Next } ret.Direct(_inner) return ret } /* calls a command (blocks until completion) * captures stdout and returns it as a string * * Example: ($ echo hello world) */ func ReadCmd(in *ast.Token, vt ast.VarTable, ft ast.FuncTable) *ast.Token { if in.Tag == ast.LIST { log.Log(log.ERR, "couldnt exec, target bin is a list", "call") return nil } out := new(bytes.Buffer) path, err := exec.LookPath(in.Value()) if err != nil { log.Log(log.ERR, "Couldnt exec " + in.Value() + ", file not found", "call") return nil } args := []string{} for i := in.Next; i != nil; i = i.Next { if i.Tag == ast.LIST { log.Log(log.ERR, "Couldnt exec " + path + ", element in arguments is a list", "call") return nil } args = append(args, i.Value()) } LaunchProcess(path, args, false, nil, out, nil) output := out.String() olen := len(output) if olen > 0 && output[olen - 1] == '\n' { output = output[:olen - 1] } ret := &ast.Token{Tag: ast.STRING} ret.Set(output) return ret } /* Takes 0 arguments * returns the exit code of the last executed program * * Example: * (sudo apt update) * (?) <- gets exit code */ func GetExit(in *ast.Token, vt ast.VarTable, ft ast.FuncTable) *ast.Token { ret := &ast.Token{Tag: ast.NUMBER} ret.Set(fmt.Sprintf("%d", LastExitCode)) return ret } /* takes an argument (pid of process to be killed) * calls Process.Kill() on it * do not use this if you already have a native implementation * (this function not added to functable in stdlib.go) */ func Kill(in *ast.Token, vt ast.VarTable, ft ast.FuncTable) *ast.Token { pid, err := strconv.ParseInt(in.Value(), 10, 64) if err != nil { log.Log(log.ERR, "error parsing arg to kill: " + err.Error(), "kill") return nil } proc, ok := JobMap[int(pid)] if !ok { log.Log(log.ERR, "Couldnt find process " + in.Value() + ", was it started by this shell?", "kill") return nil } // if this doesnt work do proc.Ctl.Kill() proc.Cancel() delete(JobMap, int(pid)) return nil }