mirror of
https://github.com/samsonjs/csc360-a1-shell.git
synced 2026-03-25 08:45:52 +00:00
113 lines
3.2 KiB
Ruby
113 lines
3.2 KiB
Ruby
require "English"
|
|
|
|
require "shell/colours"
|
|
require "shell/job"
|
|
require "shell/logger"
|
|
|
|
module Shell
|
|
class JobControl
|
|
include Colours
|
|
|
|
attr_reader :logger
|
|
|
|
def initialize(logger: nil, refresh_line: nil)
|
|
@jobs_by_pid = {}
|
|
@logger = logger || Logger.instance
|
|
@refresh_line = refresh_line || -> { Readline.refresh_line }
|
|
end
|
|
|
|
def exec_command(cmd, args, background: false)
|
|
unless (path = resolve_executable(cmd))
|
|
warn "#{red("[ERROR]")} command not found: #{cmd}"
|
|
return -2
|
|
end
|
|
|
|
pid = fork { exec([path, cmd], *args) }
|
|
if background
|
|
job = Job.new(next_job_id, pid, cmd, args)
|
|
@jobs_by_pid[pid] = job
|
|
puts white("Running job ") + yellow(job.id) + white(" (pid #{pid}) in background")
|
|
Process.waitpid(pid, Process::WNOHANG)
|
|
0
|
|
else
|
|
begin
|
|
Process.waitpid(pid)
|
|
$CHILD_STATUS.exitstatus
|
|
rescue Errno::ECHILD => e
|
|
# FIXME: why does this happen? doesn't seem to be a real problem
|
|
logger.verbose "#{yellow(e.message)} but child was just forked 🧐"
|
|
0
|
|
end
|
|
end
|
|
rescue => e
|
|
warn "#{red("[ERROR]")} #{e.message} #{e.inspect}"
|
|
-5
|
|
end
|
|
|
|
def kill(job_id)
|
|
job = @jobs_by_pid.values.detect { |j| j.id == job_id }
|
|
if job.nil?
|
|
logger.warn "No job found with ID #{job_id}"
|
|
return
|
|
end
|
|
|
|
Process.kill("TERM", job.pid)
|
|
rescue Errno::ESRCH
|
|
logger.warn "No such proccess: #{job.pid}"
|
|
end
|
|
|
|
def list
|
|
@jobs_by_pid.values.sort_by(&:id)
|
|
end
|
|
|
|
def format_job(job)
|
|
args = job.args.join(" ")
|
|
"#{yellow(job.id)}: #{white("(pid ", job.pid, ")")} #{green(job.cmd)} #{args}"
|
|
end
|
|
|
|
def trap_sigchld
|
|
# handler for SIGCHLD when a child's state changes
|
|
Signal.trap("CHLD") do |_signo|
|
|
pid = begin
|
|
Process.waitpid(-1, Process::WNOHANG)
|
|
rescue Errno::ECHILD
|
|
nil
|
|
end
|
|
if pid.nil?
|
|
# no-op
|
|
elsif (job = @jobs_by_pid[pid])
|
|
@jobs_by_pid.delete(pid)
|
|
time = Time.now.strftime("%T")
|
|
job_text = yellow("job ", job.id, " exited")
|
|
args = job.args.join(" ")
|
|
puts "\n[#{time}] #{job_text} #{white("(pid ", job.pid, ")")}: #{green(job.cmd)} #{args}"
|
|
else
|
|
warn "\n#{yellow("[WARN]")} No job found for child with PID #{pid}"
|
|
end
|
|
@refresh_line.call
|
|
end
|
|
end
|
|
|
|
# Return absolute and relative paths directly, or searches PATH for a
|
|
# matching executable with the given filename and returns its path.
|
|
# Returns nil when no such command was found.
|
|
def resolve_executable(path_or_filename)
|
|
# process absolute and relative paths directly
|
|
return path_or_filename if path_or_filename["/"] &&
|
|
File.executable?(path_or_filename)
|
|
|
|
filename = path_or_filename
|
|
ENV["PATH"].split(":").each do |dir|
|
|
path = File.join(dir, filename)
|
|
next unless File.exist?(path)
|
|
return path if File.executable?(path)
|
|
logger.warn "Found #{path} but it's not executable"
|
|
end
|
|
nil
|
|
end
|
|
|
|
def next_job_id
|
|
(@jobs_by_pid.values.map(&:id).max || 0) + 1
|
|
end
|
|
end
|
|
end
|