Use fork and exec and implement bg builtin

This commit is contained in:
Sami Samhuri 2022-01-17 00:36:20 -08:00
parent 0d0bb73114
commit ba8bf8088f
No known key found for this signature in database
GPG key ID: 4B4195422742FC16
5 changed files with 130 additions and 64 deletions

View file

@ -2,7 +2,8 @@ class Shell
class Builtins
attr_reader :logger
def initialize(logger = ShellLogger.instance)
def initialize(shell, logger = Logger.instance)
@shell = shell
@logger = logger
end
@ -18,8 +19,14 @@ class Shell
### Built-ins ###
#################
def builtin_bg(args)
cmd = args.shift
@shell.exec_command(cmd, args, background: true)
end
def builtin_cd(args)
Dir.chdir args.first
0
end
def builtin_export(args)
@ -30,10 +37,12 @@ class Shell
else
ENV[name] = value_parts.join('=').gsub(/\$\w+/) { |m| ENV[m[1..]] || '' }
end
0
end
def bulitin_pwd(_args)
puts Dir.pwd
0
end
end
end

View file

@ -1,3 +1,3 @@
class Shell
Job = Struct.new(:id, :cmd)
Job = Struct.new(:id, :pid, :cmd, :args)
end

41
ruby/logger.rb Normal file
View file

@ -0,0 +1,41 @@
require './colours'
class Shell
# Queues up messages to be printed out when readline is waiting for input, to prevent
# mixing shell output with command output.
class Logger
Log = Struct.new(:level, :message)
attr_reader :logs
def self.instance
@instance ||= new
end
def initialize
clear
end
def log(message)
@logs << Log.new(:info, "#{WHITE}[INFO]#{CLEAR} #{message}")
end
alias info log
def warn(message)
@logs << Log.new(:warning, "#{YELLOW}[WARN]#{CLEAR} #{message}")
end
def error(message)
@logs << Log.new(:error, "#{RED}[ERROR]#{CLEAR} #{message}")
end
def verbose(message)
@logs << Log.new(:verbose, "[VERBOSE] #{message}")
end
def clear
@logs = []
nil
end
end
end

View file

@ -7,33 +7,35 @@ require 'wordexp'
require './builtins'
require './colours'
require './shell_logger'
require './logger'
require './job'
class Shell
attr_reader :logger, :options
def initialize(args = ARGV)
@builtins = Builtins.new
@builtins = Builtins.new(self)
@jobs_by_pid = {}
@logger = ShellLogger.instance
@logger = Logger.instance
@options = parse_options(args)
logger.verbose "Options: #{options.inspect}"
end
def main
# this breaks Open3.capture3 so hold off until we fork + exec
# trap_sigchld
trap_sigchld
if options[:command]
logger.verbose "Executing command: #{options[:command]}"
print_logs
exit process_command(options[:command])
elsif $stdin.isatty
add_to_history = true
status = 0
loop do
print_logs
print "#{RED}#{status}#{CLEAR} " unless status.zero?
line = Readline.readline(prompt(Dir.pwd), add_to_history)
process_command(line)
Readline::HISTORY.pop if line.nil? || line.strip.empty?
status = process_command(line)
end
end
end
@ -63,13 +65,17 @@ class Shell
def trap_sigchld
# handler for SIGCHLD when a child's state changes
Signal.trap('CHLD') do |signo|
logger.info "SIGCHLD #{signo}"
Signal.trap('CHLD') do |_signo|
pid = Process.waitpid(-1, Process::WNOHANG)
if (job = @jobs_by_pid[pid])
logger.info "#{YELLOW}#{job.id}#{CLEAR}: #{WHITE}(pid #{pid})#{CLEAR} #{job.cmd}"
if pid.nil?
# no-op
elsif (job = @jobs_by_pid[pid])
puts "\n#{YELLOW}#{job.id}#{CLEAR}: " \
"#{WHITE}(pid #{pid})#{CLEAR} " \
"#{GREEN}#{job.cmd}#{CLEAR} " \
"#{job.args.inspect}"
else
logger.warn "No job found for child with PID #{pid}"
warn "\n#{YELLOW}[WARN]#{CLEAR} No job found for child with PID #{pid}"
end
end
end
@ -88,10 +94,10 @@ class Shell
end
def process_command(line)
logger.verbose "Processing command: #{line.inspect}"
exit 0 if line.nil? # EOF, ctrl-d
return if line.empty? # no input, no-op
return 0 if line.strip.empty? # no input, no-op
logger.verbose "Processing command: #{line.inspect}"
args = Wordexp.expand(line)
cmd = args.shift
logger.verbose "Parsed command: #{cmd} #{args.inspect}"
@ -100,20 +106,69 @@ class Shell
@builtins.exec(cmd, args)
else
logger.verbose "Shelling out for #{cmd}"
status = exec_command(cmd, args)
print "#{RED}-#{status}-#{CLEAR} " unless status.zero?
exec_command(cmd, args)
end
rescue Errno => e
warn "#{RED}[ERROR]#{CLEAR} #{e.message}"
-1
end
def exec_command(cmd, args)
# TODO: background execution using fork + exec, streaming output
out, err, status = Open3.capture3(cmd + ' ' + args.join(' '))
puts out.chomp unless out.empty?
warn err.chomp unless err.empty?
status.exitstatus
def exec_command(cmd, args, background: false)
unless (path = resolve_executable(cmd))
warn "#{RED}[ERROR]#{CLEAR} command not found: #{cmd}"
return -2
end
pid = fork
if pid
# parent
if background
job = Job.new(next_job_id, pid, cmd, args)
@jobs_by_pid[pid] = job
puts "Background job #{job.id} (pid #{pid})"
Process.waitpid(pid, Process::WNOHANG)
0
else
begin
Process.waitpid(pid)
$CHILD_STATUS.exitstatus
rescue Errno::ECHILD => e
# FIXME: why does this happen?
warn "#{YELLOW}[WARN]#{CLEAR} #{e.message} but child was just forked 🧐"
-3
end
end
else
# child
exec([path, cmd], *args)
# if we make it here then exec failed
-4
end
rescue StandardError => e
logger.warn "#{RED}[ERROR]#{CLEAR} #{e.message}"
1
warn "#{RED}[ERROR]#{CLEAR} #{e.message} #{e.inspect}"
-5
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

View file

@ -1,39 +0,0 @@
require './colours'
# Queues up messages to be printed out when readline is waiting for input, to prevent
# mixing shell output with command output.
class ShellLogger
Log = Struct.new(:level, :message)
attr_reader :logs
def self.instance
@instance ||= new
end
def initialize
clear
end
def log(message)
@logs << Log.new(:info, "#{WHITE}[INFO]#{CLEAR} #{message}")
end
alias info log
def warn(message)
@logs << Log.new(:warning, "#{YELLOW}[WARN]#{CLEAR} #{message}")
end
def error(message)
@logs << Log.new(:error, "#{RED}[ERROR]#{CLEAR} #{message}")
end
def verbose(message)
@logs << Log.new(:verbose, "[VERBOSE] #{message}")
end
def clear
@logs = []
nil
end
end