mirror of
https://github.com/samsonjs/csc360-a1-shell.git
synced 2026-04-27 14:57:43 +00:00
Extract job control to a separate class
This commit is contained in:
parent
97c597d628
commit
34150d64cb
7 changed files with 135 additions and 107 deletions
|
|
@ -11,7 +11,7 @@ Metrics/AbcSize:
|
||||||
Max: 25
|
Max: 25
|
||||||
|
|
||||||
Metrics/MethodLength:
|
Metrics/MethodLength:
|
||||||
Max: 20
|
Max: 30
|
||||||
|
|
||||||
Style/Documentation:
|
Style/Documentation:
|
||||||
Enabled: false
|
Enabled: false
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ PLATFORMS
|
||||||
arm64-darwin-21
|
arm64-darwin-21
|
||||||
|
|
||||||
DEPENDENCIES
|
DEPENDENCIES
|
||||||
rubocop
|
rubocop (= 1.24.1)
|
||||||
wordexp (~> 0.1)
|
wordexp (~> 0.1)
|
||||||
|
|
||||||
RUBY VERSION
|
RUBY VERSION
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@ class Shell
|
||||||
class Builtins
|
class Builtins
|
||||||
attr_reader :logger
|
attr_reader :logger
|
||||||
|
|
||||||
def initialize(shell, logger = Logger.instance)
|
def initialize(job_control, logger = Logger.instance)
|
||||||
@shell = shell
|
@job_control = job_control
|
||||||
@logger = logger
|
@logger = logger
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -21,7 +21,7 @@ class Shell
|
||||||
|
|
||||||
def builtin_bg(args)
|
def builtin_bg(args)
|
||||||
cmd = args.shift
|
cmd = args.shift
|
||||||
@shell.exec_command(cmd, args, background: true)
|
@job_control.exec_command(cmd, args, background: true)
|
||||||
end
|
end
|
||||||
|
|
||||||
def builtin_cd(args)
|
def builtin_cd(args)
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
class Shell
|
class Shell
|
||||||
|
module Colours
|
||||||
# These colours should be safe on dark and light backgrounds.
|
# These colours should be safe on dark and light backgrounds.
|
||||||
BLUE = "\033[1;34m".freeze
|
BLUE = "\033[1;34m".freeze
|
||||||
GREEN = "\033[1;32m".freeze
|
GREEN = "\033[1;32m".freeze
|
||||||
|
|
@ -7,3 +8,13 @@ class Shell
|
||||||
WHITE = "\033[1;37m".freeze
|
WHITE = "\033[1;37m".freeze
|
||||||
CLEAR = "\033[0;m".freeze
|
CLEAR = "\033[0;m".freeze
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def self.included(other)
|
||||||
|
Colours.constants.each do |sym|
|
||||||
|
next if sym == :CLEAR
|
||||||
|
other.define_method(sym.to_s.downcase) do |text|
|
||||||
|
"#{Colours.const_get(sym)}#{text}#{CLEAR}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
|
||||||
82
ruby/job_control.rb
Normal file
82
ruby/job_control.rb
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
require './colours'
|
||||||
|
require './job'
|
||||||
|
|
||||||
|
class Shell
|
||||||
|
class JobControl
|
||||||
|
include Colours
|
||||||
|
|
||||||
|
attr_reader :logger
|
||||||
|
|
||||||
|
def initialize(logger = Logger.instance)
|
||||||
|
@jobs_by_pid = {}
|
||||||
|
@logger = logger
|
||||||
|
end
|
||||||
|
|
||||||
|
def trap_sigchld
|
||||||
|
# handler for SIGCHLD when a child's state changes
|
||||||
|
Signal.trap('CHLD') do |_signo|
|
||||||
|
pid = Process.waitpid(-1, Process::WNOHANG)
|
||||||
|
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
|
||||||
|
warn "\n#{YELLOW}[WARN]#{CLEAR} No job found for child with PID #{pid}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
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 { exec([path, cmd], *args) }
|
||||||
|
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? doesn't seem to be a real problem
|
||||||
|
logger.verbose "#{YELLOW}#{e.message}#{CLEAR} but child was just forked 🧐"
|
||||||
|
0
|
||||||
|
end
|
||||||
|
end
|
||||||
|
rescue StandardError => e
|
||||||
|
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
|
||||||
|
end
|
||||||
|
|
@ -4,6 +4,8 @@ class Shell
|
||||||
# Queues up messages to be printed out when readline is waiting for input, to prevent
|
# Queues up messages to be printed out when readline is waiting for input, to prevent
|
||||||
# mixing shell output with command output.
|
# mixing shell output with command output.
|
||||||
class Logger
|
class Logger
|
||||||
|
include Colours
|
||||||
|
|
||||||
Log = Struct.new(:level, :message)
|
Log = Struct.new(:level, :message)
|
||||||
|
|
||||||
attr_reader :logs
|
attr_reader :logs
|
||||||
|
|
|
||||||
107
ruby/main.rb
107
ruby/main.rb
|
|
@ -1,33 +1,42 @@
|
||||||
#!/usr/bin/env ruby -w
|
#!/usr/bin/env ruby -w
|
||||||
|
|
||||||
require 'English'
|
require 'English'
|
||||||
require 'open3'
|
|
||||||
require 'readline'
|
require 'readline'
|
||||||
require 'wordexp'
|
require 'wordexp'
|
||||||
|
|
||||||
require './builtins'
|
require './builtins'
|
||||||
require './colours'
|
require './colours'
|
||||||
|
require './job_control'
|
||||||
require './logger'
|
require './logger'
|
||||||
require './job'
|
|
||||||
|
|
||||||
|
# TODO: change to module after extracting all or most of the code
|
||||||
class Shell
|
class Shell
|
||||||
attr_reader :logger, :options
|
include Colours
|
||||||
|
|
||||||
def initialize(args = ARGV)
|
attr_reader :builtins, :job_control, :logger, :options
|
||||||
@builtins = Builtins.new(self)
|
|
||||||
@jobs_by_pid = {}
|
def initialize(args: ARGV, builtins: nil, job_control: nil, logger: nil)
|
||||||
@logger = Logger.instance
|
logger ||= Logger.instance
|
||||||
|
job_control ||= JobControl.new
|
||||||
|
builtins ||= Builtins.new(job_control)
|
||||||
|
@builtins = builtins
|
||||||
|
@job_control = job_control
|
||||||
|
@logger = logger
|
||||||
@options = parse_options(args)
|
@options = parse_options(args)
|
||||||
logger.verbose "Options: #{options.inspect}"
|
logger.verbose "Options: #{options.inspect}"
|
||||||
end
|
end
|
||||||
|
|
||||||
def main
|
def main
|
||||||
trap_sigchld
|
|
||||||
if options[:command]
|
if options[:command]
|
||||||
logger.verbose "Executing command: #{options[:command]}"
|
logger.verbose "Executing command: #{options[:command]}"
|
||||||
print_logs
|
print_logs
|
||||||
exit process_command(options[:command])
|
exit process_command(options[:command])
|
||||||
elsif $stdin.isatty
|
end
|
||||||
|
repl if $stdin.isatty
|
||||||
|
end
|
||||||
|
|
||||||
|
def repl
|
||||||
|
@job_control.trap_sigchld
|
||||||
add_to_history = true
|
add_to_history = true
|
||||||
status = 0
|
status = 0
|
||||||
loop do
|
loop do
|
||||||
|
|
@ -38,7 +47,6 @@ class Shell
|
||||||
status = process_command(line)
|
status = process_command(line)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
|
||||||
|
|
||||||
# Looks like this: /path/to/somewhere%
|
# Looks like this: /path/to/somewhere%
|
||||||
def prompt(pwd)
|
def prompt(pwd)
|
||||||
|
|
@ -63,23 +71,6 @@ class Shell
|
||||||
options
|
options
|
||||||
end
|
end
|
||||||
|
|
||||||
def trap_sigchld
|
|
||||||
# handler for SIGCHLD when a child's state changes
|
|
||||||
Signal.trap('CHLD') do |_signo|
|
|
||||||
pid = Process.waitpid(-1, Process::WNOHANG)
|
|
||||||
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
|
|
||||||
warn "\n#{YELLOW}[WARN]#{CLEAR} No job found for child with PID #{pid}"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def print_logs
|
def print_logs
|
||||||
logger.logs.each do |log|
|
logger.logs.each do |log|
|
||||||
message = "#{log.message}#{CLEAR}"
|
message = "#{log.message}#{CLEAR}"
|
||||||
|
|
@ -106,70 +97,12 @@ class Shell
|
||||||
@builtins.exec(cmd, args)
|
@builtins.exec(cmd, args)
|
||||||
else
|
else
|
||||||
logger.verbose "Shelling out for #{cmd}"
|
logger.verbose "Shelling out for #{cmd}"
|
||||||
exec_command(cmd, args)
|
@job_control.exec_command(cmd, args)
|
||||||
end
|
end
|
||||||
rescue Errno => e
|
rescue Errno => e
|
||||||
warn "#{RED}[ERROR]#{CLEAR} #{e.message}"
|
warn "#{RED}[ERROR]#{CLEAR} #{e.message}"
|
||||||
-1
|
-1
|
||||||
end
|
end
|
||||||
|
|
||||||
def exec_command(cmd, args, background: false)
|
|
||||||
unless (path = resolve_executable(cmd))
|
|
||||||
warn "#{RED}[ERROR]#{CLEAR} command not found: #{cmd}"
|
|
||||||
return -2
|
|
||||||
end
|
end
|
||||||
|
|
||||||
pid = fork
|
Shell.new(args: ARGV).main if $PROGRAM_NAME == __FILE__
|
||||||
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? doesn't seem to be a real problem
|
|
||||||
logger.verbose "#{YELLOW}#{e.message}#{CLEAR} but child was just forked 🧐"
|
|
||||||
0
|
|
||||||
end
|
|
||||||
end
|
|
||||||
else
|
|
||||||
# child
|
|
||||||
exec([path, cmd], *args)
|
|
||||||
# if we make it here then exec failed
|
|
||||||
-4
|
|
||||||
end
|
|
||||||
rescue StandardError => e
|
|
||||||
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
|
|
||||||
|
|
||||||
Shell.new(ARGV).main if $PROGRAM_NAME == __FILE__
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue