rush-shell 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +94 -0
- data/exe/rush +10 -0
- data/grammar/shell.y +272 -0
- data/grammar/shell.y.output +2354 -0
- data/lib/rush/alias_table.rb +48 -0
- data/lib/rush/assignments.rb +48 -0
- data/lib/rush/ast/and_or.rb +37 -0
- data/lib/rush/ast/assignment.rb +20 -0
- data/lib/rush/ast/brace_group.rb +25 -0
- data/lib/rush/ast/case_node.rb +41 -0
- data/lib/rush/ast/condition_loop.rb +34 -0
- data/lib/rush/ast/for_node.rb +34 -0
- data/lib/rush/ast/function_def.rb +26 -0
- data/lib/rush/ast/if_node.rb +29 -0
- data/lib/rush/ast/list.rb +47 -0
- data/lib/rush/ast/node.rb +17 -0
- data/lib/rush/ast/param_ref.rb +59 -0
- data/lib/rush/ast/pipeline.rb +45 -0
- data/lib/rush/ast/redirect.rb +21 -0
- data/lib/rush/ast/redirected.rb +26 -0
- data/lib/rush/ast/simple_command.rb +62 -0
- data/lib/rush/ast/subshell.rb +24 -0
- data/lib/rush/ast/until_node.rb +18 -0
- data/lib/rush/ast/while_node.rb +18 -0
- data/lib/rush/ast/word.rb +44 -0
- data/lib/rush/ast/word_segment.rb +203 -0
- data/lib/rush/background_runner.rb +68 -0
- data/lib/rush/bracket_expression.rb +107 -0
- data/lib/rush/bracket_scanner.rb +64 -0
- data/lib/rush/builtins/alias_.rb +74 -0
- data/lib/rush/builtins/base.rb +96 -0
- data/lib/rush/builtins/break_.rb +19 -0
- data/lib/rush/builtins/cd.rb +127 -0
- data/lib/rush/builtins/colon.rb +16 -0
- data/lib/rush/builtins/command.rb +137 -0
- data/lib/rush/builtins/command_options.rb +103 -0
- data/lib/rush/builtins/continue_.rb +19 -0
- data/lib/rush/builtins/declare.rb +27 -0
- data/lib/rush/builtins/defaults.rb +28 -0
- data/lib/rush/builtins/dot.rb +52 -0
- data/lib/rush/builtins/echo.rb +37 -0
- data/lib/rush/builtins/eval.rb +25 -0
- data/lib/rush/builtins/exec.rb +54 -0
- data/lib/rush/builtins/exit.rb +45 -0
- data/lib/rush/builtins/export.rb +20 -0
- data/lib/rush/builtins/false_.rb +16 -0
- data/lib/rush/builtins/fd_operand.rb +87 -0
- data/lib/rush/builtins/getopts.rb +96 -0
- data/lib/rush/builtins/hash.rb +64 -0
- data/lib/rush/builtins/job_resume.rb +107 -0
- data/lib/rush/builtins/jobs.rb +86 -0
- data/lib/rush/builtins/kill.rb +152 -0
- data/lib/rush/builtins/local.rb +40 -0
- data/lib/rush/builtins/loop_jump.rb +45 -0
- data/lib/rush/builtins/printf.rb +39 -0
- data/lib/rush/builtins/printf_formatter.rb +111 -0
- data/lib/rush/builtins/pwd.rb +17 -0
- data/lib/rush/builtins/read.rb +93 -0
- data/lib/rush/builtins/read_input.rb +146 -0
- data/lib/rush/builtins/readonly.rb +20 -0
- data/lib/rush/builtins/registry.rb +36 -0
- data/lib/rush/builtins/return_.rb +24 -0
- data/lib/rush/builtins/set.rb +101 -0
- data/lib/rush/builtins/shift.rb +35 -0
- data/lib/rush/builtins/test_.rb +48 -0
- data/lib/rush/builtins/test_context.rb +24 -0
- data/lib/rush/builtins/test_expr.rb +74 -0
- data/lib/rush/builtins/test_grammar.rb +117 -0
- data/lib/rush/builtins/test_operators.rb +94 -0
- data/lib/rush/builtins/test_tokens.rb +69 -0
- data/lib/rush/builtins/times.rb +35 -0
- data/lib/rush/builtins/trap.rb +85 -0
- data/lib/rush/builtins/true_.rb +16 -0
- data/lib/rush/builtins/type_.rb +28 -0
- data/lib/rush/builtins/ulimit.rb +258 -0
- data/lib/rush/builtins/umask.rb +81 -0
- data/lib/rush/builtins/unalias.rb +46 -0
- data/lib/rush/builtins/unset.rb +40 -0
- data/lib/rush/builtins/wait.rb +127 -0
- data/lib/rush/cli.rb +31 -0
- data/lib/rush/command_assignments.rb +56 -0
- data/lib/rush/command_lookup.rb +228 -0
- data/lib/rush/command_resolution.rb +115 -0
- data/lib/rush/command_runner.rb +152 -0
- data/lib/rush/command_text.rb +140 -0
- data/lib/rush/environment.rb +134 -0
- data/lib/rush/errexit_context.rb +68 -0
- data/lib/rush/error_policy.rb +64 -0
- data/lib/rush/errors.rb +100 -0
- data/lib/rush/escape_table.rb +22 -0
- data/lib/rush/escaped_bracket.rb +49 -0
- data/lib/rush/executor.rb +164 -0
- data/lib/rush/exit_trap.rb +109 -0
- data/lib/rush/expansion/arithmetic/evaluator.rb +43 -0
- data/lib/rush/expansion/arithmetic/nodes.rb +180 -0
- data/lib/rush/expansion/arithmetic/number.rb +104 -0
- data/lib/rush/expansion/arithmetic/parser.rb +155 -0
- data/lib/rush/expansion/arithmetic/tokenizer.rb +56 -0
- data/lib/rush/expansion/arithmetic_expander.rb +38 -0
- data/lib/rush/expansion/command_substitution.rb +87 -0
- data/lib/rush/expansion/field_part.rb +12 -0
- data/lib/rush/expansion/field_splitter.rb +23 -0
- data/lib/rush/expansion/glob_expander.rb +48 -0
- data/lib/rush/expansion/ifs.rb +62 -0
- data/lib/rush/expansion/ifs_scanner.rb +168 -0
- data/lib/rush/expansion/parameter_expander.rb +204 -0
- data/lib/rush/expansion/parameter_forms.rb +22 -0
- data/lib/rush/expansion/pattern_removal.rb +63 -0
- data/lib/rush/expansion/pipeline.rb +124 -0
- data/lib/rush/expansion/read_char.rb +13 -0
- data/lib/rush/expansion/read_field_scanner.rb +159 -0
- data/lib/rush/expansion/read_splitter.rb +40 -0
- data/lib/rush/expansion/resolver.rb +23 -0
- data/lib/rush/expansion/tilde_expander.rb +102 -0
- data/lib/rush/external.rb +75 -0
- data/lib/rush/fd_entry.rb +60 -0
- data/lib/rush/for_runner.rb +50 -0
- data/lib/rush/function_frame.rb +30 -0
- data/lib/rush/function_runner.rb +37 -0
- data/lib/rush/function_table.rb +35 -0
- data/lib/rush/getopts_parser.rb +195 -0
- data/lib/rush/getopts_state.rb +104 -0
- data/lib/rush/here_doc.rb +57 -0
- data/lib/rush/interactive_signals.rb +26 -0
- data/lib/rush/invocation.rb +190 -0
- data/lib/rush/io_table.rb +94 -0
- data/lib/rush/job_control.rb +197 -0
- data/lib/rush/job_report.rb +45 -0
- data/lib/rush/job_spec.rb +51 -0
- data/lib/rush/job_table/control.rb +103 -0
- data/lib/rush/job_table/interruptible_wait.rb +70 -0
- data/lib/rush/job_table/job.rb +181 -0
- data/lib/rush/job_table.rb +194 -0
- data/lib/rush/lexer/alias_expander.rb +129 -0
- data/lib/rush/lexer/braced_reader.rb +86 -0
- data/lib/rush/lexer/case_frame.rb +114 -0
- data/lib/rush/lexer/case_tracker.rb +146 -0
- data/lib/rush/lexer/dollar_scanner.rb +53 -0
- data/lib/rush/lexer/double_quote_scanner.rb +71 -0
- data/lib/rush/lexer/heredoc_body.rb +91 -0
- data/lib/rush/lexer/heredoc_reader.rb +93 -0
- data/lib/rush/lexer/lex_state.rb +128 -0
- data/lib/rush/lexer/operator_table.rb +32 -0
- data/lib/rush/lexer/param_scanner.rb +43 -0
- data/lib/rush/lexer/paren_reader.rb +113 -0
- data/lib/rush/lexer/paren_regions.rb +74 -0
- data/lib/rush/lexer/quote_skips.rb +77 -0
- data/lib/rush/lexer/quoted_word.rb +67 -0
- data/lib/rush/lexer/scanner_predicates.rb +20 -0
- data/lib/rush/lexer/source_lines.rb +43 -0
- data/lib/rush/lexer/substitution_reader.rb +77 -0
- data/lib/rush/lexer/token_classifier.rb +144 -0
- data/lib/rush/lexer/token_predicates.rb +23 -0
- data/lib/rush/lexer/word_scanner.rb +134 -0
- data/lib/rush/lexer.rb +174 -0
- data/lib/rush/loop_control_handling.rb +29 -0
- data/lib/rush/loop_nesting.rb +50 -0
- data/lib/rush/loop_runner.rb +55 -0
- data/lib/rush/option_cluster.rb +39 -0
- data/lib/rush/options.rb +76 -0
- data/lib/rush/param_text.rb +39 -0
- data/lib/rush/parser.rb +1213 -0
- data/lib/rush/parser_support.rb +112 -0
- data/lib/rush/pattern_scanner.rb +35 -0
- data/lib/rush/pending_signals.rb +57 -0
- data/lib/rush/pipeline_runner.rb +173 -0
- data/lib/rush/pipeline_statuses.rb +45 -0
- data/lib/rush/positional.rb +45 -0
- data/lib/rush/posix_pattern.rb +78 -0
- data/lib/rush/program_input.rb +89 -0
- data/lib/rush/program_reader.rb +69 -0
- data/lib/rush/program_session.rb +83 -0
- data/lib/rush/prompt.rb +52 -0
- data/lib/rush/redirect_scope.rb +64 -0
- data/lib/rush/redirection/dup_redirect.rb +58 -0
- data/lib/rush/redirection/file_redirect.rb +50 -0
- data/lib/rush/redirection/here_doc_redirect.rb +17 -0
- data/lib/rush/redirection/registry.rb +53 -0
- data/lib/rush/repl.rb +133 -0
- data/lib/rush/runtime_type_checks.rb +16 -0
- data/lib/rush/scope.rb +79 -0
- data/lib/rush/segment_buffer.rb +47 -0
- data/lib/rush/shell_parameters.rb +77 -0
- data/lib/rush/shell_pattern.rb +80 -0
- data/lib/rush/shell_state.rb +163 -0
- data/lib/rush/shell_variables.rb +67 -0
- data/lib/rush/signal_report.rb +41 -0
- data/lib/rush/signals.rb +68 -0
- data/lib/rush/source.rb +82 -0
- data/lib/rush/source_line_counter.rb +41 -0
- data/lib/rush/source_runner.rb +47 -0
- data/lib/rush/startup.rb +75 -0
- data/lib/rush/status.rb +93 -0
- data/lib/rush/stop_relay.rb +32 -0
- data/lib/rush/subshell_runner.rb +70 -0
- data/lib/rush/system_calls/collation.rb +155 -0
- data/lib/rush/system_calls/file_tests.rb +95 -0
- data/lib/rush/system_calls/process_control.rb +165 -0
- data/lib/rush/system_calls/process_identity.rb +43 -0
- data/lib/rush/system_calls/resource_limits.rb +51 -0
- data/lib/rush/system_calls.rb +206 -0
- data/lib/rush/terminal.rb +126 -0
- data/lib/rush/trap_runner.rb +169 -0
- data/lib/rush/trap_table.rb +49 -0
- data/lib/rush/umask_mode.rb +133 -0
- data/lib/rush/version.rb +6 -0
- data/lib/rush.rb +190 -0
- metadata +527 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Rush
|
|
5
|
+
class SystemCalls
|
|
6
|
+
# Who the shell process is, mixed into SystemCalls: pids for $$/PPID,
|
|
7
|
+
# effective privileges for the PS1 default, and the invocation name for
|
|
8
|
+
# login-shell detection.
|
|
9
|
+
module ProcessIdentity
|
|
10
|
+
extend T::Sig
|
|
11
|
+
|
|
12
|
+
sig { returns(Integer) }
|
|
13
|
+
def pid
|
|
14
|
+
Process.pid
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
sig { returns(Integer) }
|
|
18
|
+
def ppid
|
|
19
|
+
Process.ppid
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# The shell's own process group: compared against the terminal's
|
|
23
|
+
# foreground group when job control acquires the tty (rush-mv8.3).
|
|
24
|
+
sig { returns(Integer) }
|
|
25
|
+
def pgrp
|
|
26
|
+
Process.getpgrp
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Whether the shell runs with root privileges: picks the default PS1
|
|
30
|
+
# ('# ' instead of '$ '), as POSIX permits for privileged users.
|
|
31
|
+
sig { returns(T::Boolean) }
|
|
32
|
+
def privileged?
|
|
33
|
+
Process.euid.zero?
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# How the process was invoked ($0): a leading '-' marks a login shell.
|
|
37
|
+
sig { returns(String) }
|
|
38
|
+
def program_name
|
|
39
|
+
$PROGRAM_NAME
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# typed: false
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Rush
|
|
5
|
+
class SystemCalls
|
|
6
|
+
# Resource-limit wrappers for the `ulimit` builtin. The mapping lives outside
|
|
7
|
+
# SystemCalls proper so the impure syscall port stays small as it grows.
|
|
8
|
+
module ResourceLimits
|
|
9
|
+
RLIMITS = {
|
|
10
|
+
cpu: Process::RLIMIT_CPU,
|
|
11
|
+
fsize: Process::RLIMIT_FSIZE,
|
|
12
|
+
data: Process::RLIMIT_DATA,
|
|
13
|
+
stack: Process::RLIMIT_STACK,
|
|
14
|
+
core: Process::RLIMIT_CORE,
|
|
15
|
+
rss: Process::RLIMIT_RSS,
|
|
16
|
+
memlock: Process::RLIMIT_MEMLOCK,
|
|
17
|
+
nproc: Process::RLIMIT_NPROC,
|
|
18
|
+
nofile: Process::RLIMIT_NOFILE,
|
|
19
|
+
as: Process::RLIMIT_AS,
|
|
20
|
+
locks: (Process.const_get(:RLIMIT_LOCKS) if Process.const_defined?(:RLIMIT_LOCKS)),
|
|
21
|
+
rtprio: Process::RLIMIT_RTPRIO
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
def current_umask
|
|
25
|
+
File.umask
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def change_umask(mask)
|
|
29
|
+
File.umask(mask)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def infinity_limit
|
|
33
|
+
Process::RLIM_INFINITY
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def getrlimit(resource)
|
|
37
|
+
limit = RLIMITS.fetch(resource)
|
|
38
|
+
return [infinity_limit, infinity_limit] unless limit
|
|
39
|
+
|
|
40
|
+
Process.getrlimit(limit)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def setrlimit(resource, soft, hard)
|
|
44
|
+
limit = RLIMITS.fetch(resource)
|
|
45
|
+
raise Errno::EINVAL unless limit
|
|
46
|
+
|
|
47
|
+
Process.setrlimit(limit, soft, hard)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# typed: false
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'etc'
|
|
5
|
+
require 'reline'
|
|
6
|
+
require 'tempfile'
|
|
7
|
+
require_relative 'system_calls/file_tests'
|
|
8
|
+
require_relative 'system_calls/collation'
|
|
9
|
+
require_relative 'system_calls/process_identity'
|
|
10
|
+
require_relative 'system_calls/process_control'
|
|
11
|
+
require_relative 'system_calls/resource_limits'
|
|
12
|
+
|
|
13
|
+
module Rush
|
|
14
|
+
# The sole impure class: every syscall rush makes is a thin wrapper here, so
|
|
15
|
+
# specs inject a fake (spec/support/fake_system_calls.rb) and reach every
|
|
16
|
+
# error branch without touching the real OS. Grows one wrapper per slice.
|
|
17
|
+
class SystemCalls
|
|
18
|
+
include FileTests
|
|
19
|
+
include ProcessIdentity
|
|
20
|
+
include ProcessControl
|
|
21
|
+
include ResourceLimits
|
|
22
|
+
|
|
23
|
+
COLLATION = Collation.new
|
|
24
|
+
|
|
25
|
+
# Run `file` as an external program with argv.first as the child's argv[0]
|
|
26
|
+
# — for the ordinary $PATH search the caller passes the bare command name
|
|
27
|
+
# as `file`; `command -p` passes its default-PATH resolution instead,
|
|
28
|
+
# without renaming the command (dash-probed via `$0`). The [cmd, argv0]
|
|
29
|
+
# form forbids the shell path even for a single-word command, so `spawn`
|
|
30
|
+
# never re-interprets words.
|
|
31
|
+
def spawn(file, env, argv, options)
|
|
32
|
+
Process.spawn(env, [file, argv.first], *argv.drop(1), options)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The system default PATH (confstr _CS_PATH): the search path `command -p`
|
|
36
|
+
# uses, guaranteed to find the standard utilities regardless of $PATH.
|
|
37
|
+
# POSIX defines _CS_PATH on every system; the coalesce only satisfies
|
|
38
|
+
# confstr's nilable signature.
|
|
39
|
+
def default_path
|
|
40
|
+
Etc.confstr(Etc::CS_PATH) || ''
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Replace the current process image (the `exec` builtin); the [cmd, argv0]
|
|
44
|
+
# form forbids the shell path, like #spawn. Returns only if the exec fails.
|
|
45
|
+
def exec(env, argv, options)
|
|
46
|
+
Process.exec(env, [argv.first, argv.first], *argv.drop(1), options)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Accumulated CPU times for the `times` builtin: a Process::Tms with utime /
|
|
50
|
+
# stime for the shell and cutime / cstime for its children. Non-deterministic,
|
|
51
|
+
# so the builtin's output is verified by format rather than differentially.
|
|
52
|
+
def times
|
|
53
|
+
Process.times
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Send a signal to a process (the `kill` builtin); signal 0 only probes that
|
|
57
|
+
# the target exists. Real signal delivery cannot run under the test harness.
|
|
58
|
+
# :nocov:
|
|
59
|
+
def kill(signal, pid)
|
|
60
|
+
Process.kill(signal, pid)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Install a signal disposition for `trap`: a command string ('IGNORE' /
|
|
64
|
+
# 'SYSTEM_DEFAULT') or, when nil, the block to run when the signal arrives. Mutating
|
|
65
|
+
# the process's real signal handlers cannot run under the test harness.
|
|
66
|
+
def trap_signal(name, command, &block)
|
|
67
|
+
Signal.trap(name, command || block)
|
|
68
|
+
end
|
|
69
|
+
# :nocov:
|
|
70
|
+
|
|
71
|
+
def pipe
|
|
72
|
+
IO.pipe
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Return a non-owning IO wrapper for an fd that is already open in the rush
|
|
76
|
+
# process (typically inherited from the parent) but not tracked by IoTable.
|
|
77
|
+
# A closed fd returns nil so redirection code can report "fd not open".
|
|
78
|
+
# Unbuffered: every `n>&9` evaluation wraps the fd anew, and buffered
|
|
79
|
+
# wrappers would flush at exit in GC order — reordering or hiding writes
|
|
80
|
+
# (rush-erq: container-reversed lines; a `cat` mid-script saw nothing
|
|
81
|
+
# where dash, which writes straight to the fd, showed the line).
|
|
82
|
+
def inherited_fd(fd)
|
|
83
|
+
return if fd.negative?
|
|
84
|
+
|
|
85
|
+
IO.for_fd(fd, autoclose: false).tap { |stream| stream.sync = true }
|
|
86
|
+
rescue Errno::EBADF
|
|
87
|
+
nil
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# fork/exit! replace or split the process and so cannot run in-process under
|
|
91
|
+
# the test harness; the child-side logic they drive is extracted into pure
|
|
92
|
+
# methods that ARE tested, and real behaviour is covered by subprocess specs.
|
|
93
|
+
# exit! flushes the standard streams first: $stdout is unbuffered only when a
|
|
94
|
+
# tty, so a forked child running a builtin would otherwise lose its output.
|
|
95
|
+
# :nocov:
|
|
96
|
+
def fork(&blk)
|
|
97
|
+
Process.fork(&blk)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def exit!(code)
|
|
101
|
+
stdout.flush
|
|
102
|
+
stderr.flush
|
|
103
|
+
Process.exit!(code)
|
|
104
|
+
end
|
|
105
|
+
# :nocov:
|
|
106
|
+
|
|
107
|
+
def chdir(path)
|
|
108
|
+
Dir.chdir(path)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def pwd
|
|
112
|
+
Dir.pwd
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def expand_path(path, base)
|
|
116
|
+
File.expand_path(path, base)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def fnmatch?(pattern, str, locale: COLLATION.default_settings)
|
|
120
|
+
COLLATION.match_shell?(pattern, str, locale) { ShellPattern.new(pattern).match?(str) }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Pathname expansion: libc filters widened Dir.glob candidates and orders
|
|
124
|
+
# exact matches by LC_COLLATE; unsupported libcs retain the Ruby fallback.
|
|
125
|
+
def glob(pattern, locale: COLLATION.default_settings)
|
|
126
|
+
COLLATION.glob(pattern, locale) do
|
|
127
|
+
ShellPattern.new(pattern).then { |shell_pattern| Dir.glob(shell_pattern.glob_source).grep(shell_pattern) }
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Sync so a builtin's write reaches the file immediately — like a pipe write
|
|
132
|
+
# end (sync by default), this lets a forked subshell's output survive its
|
|
133
|
+
# exit! and be visible to a later command. File.new, not File.open: the
|
|
134
|
+
# redirection keeps the file open past this call — the caller owns the
|
|
135
|
+
# handle until close_redirect releases it, so the auto-closing block form
|
|
136
|
+
# would be wrong here.
|
|
137
|
+
def open_file(path, mode)
|
|
138
|
+
File.new(path, mode).tap { |io| io.sync = true }
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Flush and release a file a redirection opened, so a later command in the
|
|
142
|
+
# same shell sees the data and the fd does not leak.
|
|
143
|
+
def close_redirect(io)
|
|
144
|
+
io.close
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def read_file(path)
|
|
148
|
+
File.read(path)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# A readable stream carrying a here-document body (a real fd, via a tempfile,
|
|
152
|
+
# so spawned children can read it).
|
|
153
|
+
def here_doc(body)
|
|
154
|
+
Tempfile.new('rush-heredoc').tap do |file|
|
|
155
|
+
file.write(body)
|
|
156
|
+
file.rewind
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def stdin
|
|
161
|
+
$stdin
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def stdout
|
|
165
|
+
$stdout
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def stderr
|
|
169
|
+
$stderr
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Interactive-REPL support: read one line of input (nil at EOF) and report
|
|
173
|
+
# whether standard input / standard error are terminals (POSIX interactivity
|
|
174
|
+
# requires both).
|
|
175
|
+
def read_line
|
|
176
|
+
stdin.gets
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Interactive line editing: Reline draws the prompt (on stderr, like the
|
|
180
|
+
# plain path), records the line into its in-memory history, and returns nil
|
|
181
|
+
# at EOF. A real-terminal path the test harness cannot drive; the Docker
|
|
182
|
+
# gate's pty smoke exercises it.
|
|
183
|
+
# :nocov:
|
|
184
|
+
def edit_line(prompt)
|
|
185
|
+
Reline.output = stderr
|
|
186
|
+
Reline.readline(prompt, true)
|
|
187
|
+
end
|
|
188
|
+
# :nocov:
|
|
189
|
+
|
|
190
|
+
def tty?
|
|
191
|
+
stdin.tty?
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def stderr_tty?
|
|
195
|
+
stderr.tty?
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Home directory of a named user for ~user tilde expansion, or nil if there
|
|
199
|
+
# is no such user.
|
|
200
|
+
def home_dir(name)
|
|
201
|
+
Etc.getpwnam(name).dir
|
|
202
|
+
rescue ArgumentError
|
|
203
|
+
nil
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Rush
|
|
5
|
+
# The shell's grip on its controlling terminal under job control
|
|
6
|
+
# (rush-mv8.3): who to hand it to, how to take it back, and what to restore
|
|
7
|
+
# when monitor turns off. Acquired by JobControl#enable, dash's setjobctl:
|
|
8
|
+
# `home` is the shell's own process group once it has made itself a leader,
|
|
9
|
+
# `initial` the foreground group found at acquisition — where the terminal
|
|
10
|
+
# returns on `set +m`. Durable job-control state, so it lives on the
|
|
11
|
+
# JobTable beside the root-shell bit; forked children drop it with the rest
|
|
12
|
+
# of the table.
|
|
13
|
+
class Terminal
|
|
14
|
+
extend T::Sig
|
|
15
|
+
|
|
16
|
+
# dash setjobctl(1): find the tty, wait until the shell is in the
|
|
17
|
+
# foreground, remember the terminal's owner, then become a process-group
|
|
18
|
+
# leader and take the terminal. Probed: the dance runs whenever the tty
|
|
19
|
+
# is reachable — interactive or not — and only an interactive shell (the
|
|
20
|
+
# caller's concern) treats its failure as an error.
|
|
21
|
+
sig { params(system: SystemCalls).returns(T.nilable(Terminal)) }
|
|
22
|
+
def self.acquire(system)
|
|
23
|
+
tty = system.open_tty
|
|
24
|
+
return unless tty
|
|
25
|
+
|
|
26
|
+
initial = wait_foreground(system, tty)
|
|
27
|
+
initial ? take(system, tty, initial) : abandon(tty)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Wait for the foreground: a shell started in the background must not
|
|
31
|
+
# steal the terminal, so it stops itself (SIGTTIN to its own group, the
|
|
32
|
+
# OS default for a background tty read) until a job-control parent
|
|
33
|
+
# brings it to the front — dash's killpg(0, SIGTTIN) loop. An unreadable
|
|
34
|
+
# foreground group (tcgetpgrp answering nothing) falls out as nil.
|
|
35
|
+
sig { params(system: SystemCalls, tty: T.untyped).returns(T.nilable(Integer)) }
|
|
36
|
+
def self.wait_foreground(system, tty)
|
|
37
|
+
while (pgrp = system.tcgetpgrp(tty))
|
|
38
|
+
return pgrp if pgrp == system.pgrp
|
|
39
|
+
|
|
40
|
+
system.kill('TTIN', 0)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Become a process-group leader and take the tty (dash: setpgid(0,
|
|
45
|
+
# rootpid); xtcsetpgrp): `home` — where foreground jobs return the
|
|
46
|
+
# terminal — is the shell's own group, its pid, from here on.
|
|
47
|
+
sig { params(system: SystemCalls, tty: T.untyped, initial: Integer).returns(Terminal) }
|
|
48
|
+
def self.take(system, tty, initial)
|
|
49
|
+
system.setpgid(0, 0)
|
|
50
|
+
new(system: system, tty: tty, home: system.pid, initial: initial).tap(&:reclaim)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# A tty whose foreground group cannot be read is no terminal at all
|
|
54
|
+
# (dash: tcgetpgrp failing is the "can't access tty" case).
|
|
55
|
+
sig { params(tty: T.untyped).returns(NilClass) }
|
|
56
|
+
def self.abandon(tty)
|
|
57
|
+
tty.close
|
|
58
|
+
nil
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# The parent-side handover around a foreground wait: give for the
|
|
62
|
+
# block, reclaim after — bare when no terminal is held (or for a fake
|
|
63
|
+
# fork's pid-0 launch).
|
|
64
|
+
sig do
|
|
65
|
+
type_parameters(:U)
|
|
66
|
+
.params(terminal: T.nilable(Terminal), leader: Integer, blk: T.proc.returns(T.type_parameter(:U)))
|
|
67
|
+
.returns(T.type_parameter(:U))
|
|
68
|
+
end
|
|
69
|
+
def self.while_held(terminal, leader, &blk)
|
|
70
|
+
return yield unless terminal && leader.positive?
|
|
71
|
+
|
|
72
|
+
terminal.while_given(leader, &blk)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private_class_method :wait_foreground, :take, :abandon
|
|
76
|
+
|
|
77
|
+
sig { returns(T.untyped) }
|
|
78
|
+
attr_reader :tty
|
|
79
|
+
|
|
80
|
+
sig { params(system: SystemCalls, tty: T.untyped, home: Integer, initial: Integer).void }
|
|
81
|
+
def initialize(system:, tty:, home:, initial:)
|
|
82
|
+
@system = system
|
|
83
|
+
@tty = tty
|
|
84
|
+
@home = home
|
|
85
|
+
@initial = initial
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Hand the terminal to a foreground job's process group (dash forkchild).
|
|
89
|
+
sig { params(pgid: Integer).void }
|
|
90
|
+
def give(pgid)
|
|
91
|
+
@system.tcsetpgrp(@tty, pgid)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Take the terminal back once the foreground job settles (dash
|
|
95
|
+
# waitforjob).
|
|
96
|
+
sig { void }
|
|
97
|
+
def reclaim
|
|
98
|
+
give(@home)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# A foreground job's whole run: the job's group holds the terminal for
|
|
102
|
+
# the duration of the block (the wait), and the shell takes it back
|
|
103
|
+
# however the wait ends.
|
|
104
|
+
sig do
|
|
105
|
+
type_parameters(:U)
|
|
106
|
+
.params(pgid: Integer, blk: T.proc.returns(T.type_parameter(:U)))
|
|
107
|
+
.returns(T.type_parameter(:U))
|
|
108
|
+
end
|
|
109
|
+
def while_given(pgid, &blk)
|
|
110
|
+
give(pgid)
|
|
111
|
+
yield
|
|
112
|
+
ensure
|
|
113
|
+
reclaim
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# `set +m`: the terminal returns to whoever owned it at acquisition, the
|
|
117
|
+
# shell rejoins that group, and the tty handle is released (dash
|
|
118
|
+
# setjobctl(0)).
|
|
119
|
+
sig { void }
|
|
120
|
+
def restore
|
|
121
|
+
give(@initial)
|
|
122
|
+
@system.setpgid(0, @initial)
|
|
123
|
+
@tty.close
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Rush
|
|
5
|
+
# Owns the executor's signal/trap handling: it records a trap, installs or
|
|
6
|
+
# clears the matching OS disposition, runs a delivered signal's action (with $?
|
|
7
|
+
# preserved, POSIX 2.14), and runs the EXIT trap as the shell terminates. Trap
|
|
8
|
+
# bodies are parsed and run back through the executor, so this collaborates with
|
|
9
|
+
# it rather than re-implementing execution.
|
|
10
|
+
class TrapRunner
|
|
11
|
+
extend T::Sig
|
|
12
|
+
|
|
13
|
+
sig { params(executor: Executor).void }
|
|
14
|
+
def initialize(executor)
|
|
15
|
+
@executor = executor
|
|
16
|
+
@exit_trap = ExitTrap.new(executor)
|
|
17
|
+
@pending = PendingSignals.new
|
|
18
|
+
@base = T.let(
|
|
19
|
+
{}, #: Hash[String, Proc]
|
|
20
|
+
T::Hash[String, Proc]
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# The session's base dispositions (the interactive INT/QUIT/TERM handlers):
|
|
25
|
+
# what `trap - SIG` and an untouched signal fall back to instead of the OS
|
|
26
|
+
# default. Subshells drop them (POSIX: caught signals reset in subshells).
|
|
27
|
+
sig { params(handlers: T::Hash[String, Proc]).void }
|
|
28
|
+
def install_base(handlers)
|
|
29
|
+
@base = handlers
|
|
30
|
+
handlers.each_key { |name| install_signal(name, :default) }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Flip one base disposition without disturbing the rest — `set -m` adds and
|
|
34
|
+
# removes SIGTSTP's this way mid-session (nil removes). A user trap on the
|
|
35
|
+
# signal keeps the disposition it installed (dash-verified in both orders);
|
|
36
|
+
# the base table still updates underneath, so a later `trap -` falls back
|
|
37
|
+
# correctly.
|
|
38
|
+
sig { params(name: String, handler: T.nilable(Proc)).void }
|
|
39
|
+
def set_base(name, handler)
|
|
40
|
+
@base.delete(name)
|
|
41
|
+
@base[name] = handler if handler
|
|
42
|
+
install_signal(name, :default) unless state.traps.action(name)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Run the EXIT trap (if any) as the shell terminates, returning the status the
|
|
46
|
+
# shell exits with: the given code, unless the trap itself runs `exit`. $?
|
|
47
|
+
# inside the trap is that same code (POSIX 2.14), so it is published first.
|
|
48
|
+
sig { params(code: Integer).returns(Integer) }
|
|
49
|
+
def run_exit_trap(code)
|
|
50
|
+
run_pending
|
|
51
|
+
@exit_trap.run(code)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Evaluate caught traps only at explicit shell checkpoints, never from the
|
|
55
|
+
# Ruby Signal.trap callback. No delivery guard: dash permits nested and
|
|
56
|
+
# same-signal traps to run at command boundaries inside an action.
|
|
57
|
+
sig { void }
|
|
58
|
+
def run_pending
|
|
59
|
+
@pending.drain { |name| deliver(name) }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
sig { params(status: Status).returns(Status) }
|
|
63
|
+
def complete(status)
|
|
64
|
+
state.record_status(status).tap { run_pending }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
sig { returns(T::Boolean) }
|
|
68
|
+
def pending?
|
|
69
|
+
@pending.any?
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
sig { returns(T.nilable(Integer)) }
|
|
73
|
+
def pending_exitstatus
|
|
74
|
+
@pending.first&.then { |name| Signals.number(name) + 128 }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# The status a bare `exit` reports: while the EXIT trap runs, the status the
|
|
78
|
+
# shell is terminating with (POSIX), not the trap body's last $?; otherwise
|
|
79
|
+
# the last command's status.
|
|
80
|
+
sig { returns(Integer) }
|
|
81
|
+
def exiting_status
|
|
82
|
+
@exit_trap.status
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Record a trap and (for real signals, not EXIT) install its disposition so a
|
|
86
|
+
# delivered signal runs the action / is ignored / restores the default.
|
|
87
|
+
sig { params(name: String, action: String).void }
|
|
88
|
+
def set(name, action)
|
|
89
|
+
state.traps.set(name, action)
|
|
90
|
+
install_signal(name, action)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
sig { params(name: String).void }
|
|
94
|
+
def reset(name)
|
|
95
|
+
state.traps.clear(name)
|
|
96
|
+
install_signal(name, :default)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
sig { void }
|
|
100
|
+
def reset_caught_for_subshell
|
|
101
|
+
@exit_trap = ExitTrap.new(@executor)
|
|
102
|
+
@pending.clear
|
|
103
|
+
drop_base
|
|
104
|
+
state.traps.reset_caught.each { |name| install_signal(name, :default) }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
sig { returns(ShellState) }
|
|
110
|
+
def state
|
|
111
|
+
@executor.state
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
sig { params(action: String).void }
|
|
115
|
+
def fire(action)
|
|
116
|
+
@executor.run(Parser.new(Lexer.new(action, aliases: state.aliases)).parse)
|
|
117
|
+
rescue Error => e
|
|
118
|
+
raise unless ErrorPolicy.decision(:signal_trap, e) == :ignore
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# An untrappable signal (KILL/STOP) raises; keep the table entry like dash.
|
|
122
|
+
sig { params(name: String, action: T.any(String, Symbol)).void }
|
|
123
|
+
def install_signal(name, action)
|
|
124
|
+
return if name == Signals::EXIT
|
|
125
|
+
|
|
126
|
+
install(name, disposition(action))
|
|
127
|
+
rescue ArgumentError, SystemCallError
|
|
128
|
+
nil
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# :default falls back to the session's base handler when one is installed
|
|
132
|
+
# (an interactive shell survives INT/QUIT/TERM); otherwise the OS default.
|
|
133
|
+
sig { params(name: String, disposition: T.nilable(String)).void }
|
|
134
|
+
def install(name, disposition)
|
|
135
|
+
base = @base.fetch(name, nil)
|
|
136
|
+
return @executor.system.trap_signal(name, nil) { base.call } if disposition == 'SYSTEM_DEFAULT' && base
|
|
137
|
+
|
|
138
|
+
@executor.system.trap_signal(name, disposition) { @pending.record(name) }
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Subshells reset the base handlers to the true OS default before the
|
|
142
|
+
# per-trap reset, so a forked child dies on ^C like any non-interactive
|
|
143
|
+
# process.
|
|
144
|
+
sig { void }
|
|
145
|
+
def drop_base
|
|
146
|
+
dropped = @base
|
|
147
|
+
@base = {}
|
|
148
|
+
dropped.each_key { |name| install_signal(name, :default) }
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# '' ignores the signal; :default restores the true OS disposition
|
|
152
|
+
# (SYSTEM_DEFAULT = SIG_DFL — Ruby's plain 'DEFAULT' instead simulates the
|
|
153
|
+
# signal by raising, which dies as an uncaught-exception traceback where
|
|
154
|
+
# dash dies silently by signal); a command string installs the handler
|
|
155
|
+
# block (nil disposition), matching SystemCalls#trap_signal.
|
|
156
|
+
sig { params(action: T.any(String, Symbol)).returns(T.nilable(String)) }
|
|
157
|
+
def disposition(action)
|
|
158
|
+
{ '' => 'IGNORE', :default => 'SYSTEM_DEFAULT' }.fetch(action, nil)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Run a delivered signal's action, restoring $? so the interrupted code is
|
|
162
|
+
# unaffected (POSIX 2.14); an `exit` in the action propagates and terminates.
|
|
163
|
+
sig { params(name: String).void }
|
|
164
|
+
def deliver(name)
|
|
165
|
+
action = state.traps.action(name)
|
|
166
|
+
state.preserve_status { fire(action) } if action
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Rush
|
|
5
|
+
# The shell's trap actions, keyed by canonical signal name (see Rush::Signals).
|
|
6
|
+
# An action is the command string to run when the signal fires; "" means the
|
|
7
|
+
# signal is ignored. Resetting a signal drops its entry, restoring the default.
|
|
8
|
+
class TrapTable
|
|
9
|
+
extend T::Sig
|
|
10
|
+
|
|
11
|
+
sig { void }
|
|
12
|
+
def initialize
|
|
13
|
+
@actions = {}
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
sig { params(name: String, action: String).returns(String) }
|
|
17
|
+
def set(name, action)
|
|
18
|
+
@actions[name] = action
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
sig { params(name: String).returns(T.nilable(String)) }
|
|
22
|
+
def clear(name)
|
|
23
|
+
@actions.delete(name)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
sig { params(name: String).returns(T.nilable(String)) }
|
|
27
|
+
def action(name)
|
|
28
|
+
@actions.fetch(name, nil)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
sig { returns(T::Array[String]) }
|
|
32
|
+
def reset_caught
|
|
33
|
+
caught.each { |name| @actions.delete(name) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# [name, action] pairs ordered by signal number, for `trap` with no operands.
|
|
37
|
+
sig { returns(T::Array[[String, String]]) }
|
|
38
|
+
def listing
|
|
39
|
+
@actions.sort_by { |name, _action| Signals.number(name) }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
sig { returns(T::Array[String]) }
|
|
45
|
+
def caught
|
|
46
|
+
@actions.filter_map { |name, action| name unless action.empty? }
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|