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,94 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Rush
|
|
5
|
+
# Maps file descriptors to fd entries for a single command. Redirections fold
|
|
6
|
+
# over a base table to produce a new one (`with`), so the shell's own streams
|
|
7
|
+
# are never mutated and a command's redirections are scoped to that command.
|
|
8
|
+
class IoTable
|
|
9
|
+
extend T::Sig
|
|
10
|
+
|
|
11
|
+
sig { params(entries: T::Hash[Integer, T.untyped]).void }
|
|
12
|
+
def initialize(entries)
|
|
13
|
+
@entries = entries.transform_values { |entry| fd_entry(entry) }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
sig { params(system: SystemCalls).returns(IoTable) }
|
|
17
|
+
def self.standard(system)
|
|
18
|
+
new(0 => FdEntry.borrowed(system.stdin),
|
|
19
|
+
1 => FdEntry.borrowed(system.stdout),
|
|
20
|
+
2 => FdEntry.borrowed(system.stderr))
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
sig { params(fd: Integer).returns(T.untyped) }
|
|
24
|
+
def get(fd)
|
|
25
|
+
entry(fd)&.stream
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
sig { params(fd: Integer).returns(T.nilable(FdEntry)) }
|
|
29
|
+
def entry(fd)
|
|
30
|
+
@entries.fetch(fd, nil)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
sig { params(fd: Integer, io: T.untyped).returns(IoTable) }
|
|
34
|
+
def with(fd, io)
|
|
35
|
+
with_entry(fd, FdEntry.borrowed(io))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
sig { params(fd: Integer, io: T.untyped).returns(IoTable) }
|
|
39
|
+
def with_owned(fd, io)
|
|
40
|
+
with_entry(fd, FdEntry.owned(io))
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
sig { params(fd: Integer).returns(IoTable) }
|
|
44
|
+
def with_closed(fd)
|
|
45
|
+
with_entry(fd, FdEntry.closed)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
sig { params(fd: Integer, entry: FdEntry).returns(IoTable) }
|
|
49
|
+
def with_entry(fd, entry)
|
|
50
|
+
self.class.new(@entries.merge(fd => entry))
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The bound entries, for diffing which redirections freshly opened owned
|
|
54
|
+
# streams over the base table. Borrowed entries (stdio, pipes and inherited
|
|
55
|
+
# fds) are intentionally not closed by redirect cleanup.
|
|
56
|
+
sig { returns(T::Array[FdEntry]) }
|
|
57
|
+
def entries
|
|
58
|
+
@entries.values
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Owned entries that appeared while moving from `base` to this table. This
|
|
62
|
+
# includes newly opened redirect files/heredocs, but not borrowed stdio,
|
|
63
|
+
# pipes, inherited fds or dup aliases of entries already present in `base`.
|
|
64
|
+
sig { params(base: IoTable).returns(T::Array[FdEntry]) }
|
|
65
|
+
def opened_over(base)
|
|
66
|
+
(entries - base.entries).select(&:owned?)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Flush+close the streams this table opened over `base` (the ones a command's
|
|
70
|
+
# redirects added), leaving inherited streams and pipe ends untouched.
|
|
71
|
+
sig { params(base: IoTable, system: SystemCalls).void }
|
|
72
|
+
def close_opened_over(base, system)
|
|
73
|
+
self.class.close_entries(opened_over(base), system)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
sig { params(entries: T::Array[FdEntry], system: SystemCalls).void }
|
|
77
|
+
def self.close_entries(entries, system)
|
|
78
|
+
entries.uniq.each { |entry| entry.close_redirect(system) }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# A closed fd becomes :close so a spawned child closes it.
|
|
82
|
+
sig { returns(T::Hash[Integer, T.untyped]) }
|
|
83
|
+
def to_spawn_options
|
|
84
|
+
@entries.transform_values(&:to_spawn_option)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
sig { params(entry: T.untyped).returns(FdEntry) }
|
|
90
|
+
def fd_entry(entry)
|
|
91
|
+
entry.is_a?(FdEntry) ? entry : FdEntry.borrowed(entry)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Rush
|
|
5
|
+
# The monitor-mode (`set -m`) policy: whether job control is active here,
|
|
6
|
+
# and the side effects of flipping the flag. Stateless — Executor builds one
|
|
7
|
+
# on demand; the durable bits (root shell, acquired terminal) live on the
|
|
8
|
+
# JobTable's Control. dash 0.5.13 is the oracle throughout (probed on and
|
|
9
|
+
# off a tty, journal): enabling monitor ignores SIGTSTP in the shell — as a
|
|
10
|
+
# base disposition, so a user trap overrides it and `trap -` restores it —
|
|
11
|
+
# and puts every forked job (background list, pipeline, subshell, external
|
|
12
|
+
# command) into its own process group, first process as leader; command
|
|
13
|
+
# substitution stays in the shell's group. Whenever the controlling
|
|
14
|
+
# terminal is reachable — interactive or not — the shell additionally does
|
|
15
|
+
# dash's setjobctl dance: wait until it is in the foreground, remember the
|
|
16
|
+
# terminal's owner, make itself a process-group leader, take the terminal,
|
|
17
|
+
# and ignore SIGTTOU too; foreground jobs then own the tty for the length
|
|
18
|
+
# of their run. Only an interactive shell treats a missing tty as an error
|
|
19
|
+
# (warn, flag off). The machinery is root-shell-only: a forked child keeps
|
|
20
|
+
# `m` in $- but re-enabling there is flag-only, exactly like dash's
|
|
21
|
+
# rootshell guard. Its raw Options#set(:monitor, ...) calls are deliberate:
|
|
22
|
+
# this policy is the sole live-executor owner of both that flag and its side
|
|
23
|
+
# effects; all ordinary option writes route through ShellState#set_option.
|
|
24
|
+
class JobControl
|
|
25
|
+
extend T::Sig
|
|
26
|
+
|
|
27
|
+
IGNORE = T.let(proc {}, Proc)
|
|
28
|
+
|
|
29
|
+
sig { params(executor: Executor).void }
|
|
30
|
+
def initialize(executor)
|
|
31
|
+
@executor = executor
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# `set -m` (and -o monitor, and invocation-time -m via #startup). Can
|
|
35
|
+
# refuse — no platform support, or an interactive shell without a tty —
|
|
36
|
+
# in which case the flag stays off and the failure is only a warning, as
|
|
37
|
+
# dash's setjobctl does. Re-enabling while on is a no-op (dash: on ==
|
|
38
|
+
# jobctl), preserving the remembered terminal owner.
|
|
39
|
+
sig { params(stderr: T.untyped).void }
|
|
40
|
+
def enable(stderr)
|
|
41
|
+
return refuse(stderr, 'job control not supported') unless @executor.system.job_control_supported?
|
|
42
|
+
return if options.on?(:monitor)
|
|
43
|
+
return options.set(:monitor, true) unless control.root
|
|
44
|
+
|
|
45
|
+
enable_root(stderr)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# `set +m`: the flag drops, SIGTSTP and SIGTTOU return to the OS default
|
|
49
|
+
# (dash setjobctl(0) runs both setsignals unconditionally), and the
|
|
50
|
+
# terminal goes back to whoever owned it at acquisition.
|
|
51
|
+
sig { void }
|
|
52
|
+
def disable
|
|
53
|
+
options.set(:monitor, false)
|
|
54
|
+
drop_monitor if control.root
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Invocation-time -m (explicit, or defaulted on for an interactive
|
|
58
|
+
# shell like dash's mflag = iflag): the flag was set while building the
|
|
59
|
+
# shell state, before any executor existed to carry the side effects.
|
|
60
|
+
# Re-run it through #enable so an interactive shell without a tty drops
|
|
61
|
+
# the flag (dash: "can't access tty" at startup), and a permitted one
|
|
62
|
+
# acquires the terminal and its base dispositions.
|
|
63
|
+
sig { void }
|
|
64
|
+
def startup
|
|
65
|
+
return unless options.on?(:monitor)
|
|
66
|
+
|
|
67
|
+
options.set(:monitor, false)
|
|
68
|
+
enable(@executor.system.stderr)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Process grouping is active: monitor is on and this is the root shell.
|
|
72
|
+
# Forked children inherit `m` in $- but none of the machinery (dash's
|
|
73
|
+
# rootshell guard, probed via nested groups and subshell `set -m`).
|
|
74
|
+
sig { returns(T::Boolean) }
|
|
75
|
+
def monitored?
|
|
76
|
+
control.monitor?
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Fork one member of a foreground job, into `leader`'s process group
|
|
80
|
+
# (its own when none) under job control; a group leader also takes the
|
|
81
|
+
# terminal, child-side, when the shell holds one. Grouping is decided
|
|
82
|
+
# before the fork, so a child entering its subshell environment cannot
|
|
83
|
+
# flip it.
|
|
84
|
+
sig { params(leader: T.nilable(Integer), child_main: T.proc.returns(T.untyped)).returns(Integer) }
|
|
85
|
+
def launch(leader: nil, &child_main)
|
|
86
|
+
return @executor.system.fork(&child_main) || 0 unless monitored?
|
|
87
|
+
|
|
88
|
+
group = leader&.positive? ? leader : 0
|
|
89
|
+
@executor.system.fork_grouped(group, handover_tty(group), &child_main) || 0
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# An asynchronous (&) job: its own group under job control, but never
|
|
93
|
+
# the terminal — probed: the tty stays with the shell.
|
|
94
|
+
sig { params(child_main: T.proc.returns(T.untyped)).returns(Integer) }
|
|
95
|
+
def launch_background(&child_main)
|
|
96
|
+
return @executor.system.fork(&child_main) || 0 unless monitored?
|
|
97
|
+
|
|
98
|
+
@executor.system.fork_grouped(0, &child_main) || 0
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Wait for a foreground job with the terminal handed to its process
|
|
102
|
+
# group, taking it back once the job settles, however the wait ends
|
|
103
|
+
# (dash's waitforjob). The give here is the parent-side settle — it also
|
|
104
|
+
# covers the spawn path, which has no child-side hook — and without a
|
|
105
|
+
# held terminal the wait runs bare. A wait that answers "stopped" (^Z
|
|
106
|
+
# under the WUNTRACED waits) parks the whole job in the table on the
|
|
107
|
+
# way out, where jobs/wait/kill find it.
|
|
108
|
+
sig { params(pids: T::Array[Integer], text: T.nilable(String), blk: T.proc.returns(Status)).returns(Status) }
|
|
109
|
+
def foreground(pids, text: nil, &blk)
|
|
110
|
+
status = Terminal.while_held(control.terminal, pids.fetch(0), &blk)
|
|
111
|
+
@executor.jobs.adopt_stopped(pids, T.must(status.stopsig), text) if status.stopped?
|
|
112
|
+
status
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# The command text a job-table entry keeps: rendered only under job
|
|
116
|
+
# control — dash stores cmdtext for jobctl jobs alone, and that absence
|
|
117
|
+
# is fg/bg's refusal bit.
|
|
118
|
+
sig { params(node: AST::Node).returns(T.nilable(String)) }
|
|
119
|
+
def job_text(node)
|
|
120
|
+
CommandText.render(node) if control.monitor?
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
private
|
|
124
|
+
|
|
125
|
+
# The root-shell side of `set -m`: with a reachable tty, the full dash
|
|
126
|
+
# setjobctl dance; without one, grouping only — an error only for an
|
|
127
|
+
# interactive shell.
|
|
128
|
+
sig { params(stderr: T.untyped).void }
|
|
129
|
+
def enable_root(stderr)
|
|
130
|
+
terminal = Terminal.acquire(@executor.system)
|
|
131
|
+
terminal ? enable_with_terminal(terminal) : enable_off_tty(stderr)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# SIGTSTP and SIGTTOU ignored (probed under a pty; SIGTTIN keeps the OS
|
|
135
|
+
# default — that is what parks a background-started shell until fg), as
|
|
136
|
+
# base dispositions: user traps win in either order and `trap -` falls
|
|
137
|
+
# back here.
|
|
138
|
+
sig { params(terminal: Terminal).void }
|
|
139
|
+
def enable_with_terminal(terminal)
|
|
140
|
+
control.engage(terminal)
|
|
141
|
+
@executor.trap_runner.set_base('TSTP', IGNORE)
|
|
142
|
+
@executor.trap_runner.set_base('TTOU', IGNORE)
|
|
143
|
+
options.set(:monitor, true)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Off-tty monitor is grouping, WUNTRACED waits and the SIGTSTP ignore
|
|
147
|
+
# alone (probed: dash leaves TTOU stopping the shell there).
|
|
148
|
+
sig { params(stderr: T.untyped).void }
|
|
149
|
+
def enable_off_tty(stderr)
|
|
150
|
+
return refuse(stderr, "can't access tty; job control turned off") if interactive?
|
|
151
|
+
|
|
152
|
+
control.engage(nil)
|
|
153
|
+
@executor.trap_runner.set_base('TSTP', IGNORE)
|
|
154
|
+
options.set(:monitor, true)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# The root-shell side of `set +m`: both stop-signal bases return to the
|
|
158
|
+
# OS default and the terminal, when held, goes home.
|
|
159
|
+
sig { void }
|
|
160
|
+
def drop_monitor
|
|
161
|
+
@executor.trap_runner.set_base('TSTP', nil)
|
|
162
|
+
@executor.trap_runner.set_base('TTOU', nil)
|
|
163
|
+
control.terminal&.restore
|
|
164
|
+
control.release
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# The tty a freshly forked job leader should take with it (dash
|
|
168
|
+
# forkchild's FORK_FG xtcsetpgrp): only a group leader, only while the
|
|
169
|
+
# shell holds the terminal. Joiners find the group already in front.
|
|
170
|
+
sig { params(group: Integer).returns(T.untyped) }
|
|
171
|
+
def handover_tty(group)
|
|
172
|
+
return if group.nonzero?
|
|
173
|
+
|
|
174
|
+
control.terminal&.tty
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
sig { params(stderr: T.untyped, message: String).void }
|
|
178
|
+
def refuse(stderr, message)
|
|
179
|
+
stderr.puts("rush: #{message}")
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
sig { returns(JobTable::Control) }
|
|
183
|
+
def control
|
|
184
|
+
@executor.jobs.control
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
sig { returns(T::Boolean) }
|
|
188
|
+
def interactive?
|
|
189
|
+
options.on?(:interactive)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
sig { returns(Options) }
|
|
193
|
+
def options
|
|
194
|
+
@executor.state.options
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Rush
|
|
5
|
+
# One line of the jobs listing — and, verbatim, of a pre-prompt
|
|
6
|
+
# notification: "[n] mark " (plus "pid " under jobs -l), the state, padding
|
|
7
|
+
# so the command text starts at column 34, then the text itself (empty
|
|
8
|
+
# outside job control, where dash keeps none either). dash's showjob
|
|
9
|
+
# format, shared by the jobs builtin and the Repl's notifier.
|
|
10
|
+
module JobReport
|
|
11
|
+
extend T::Sig
|
|
12
|
+
|
|
13
|
+
sig { params(table: JobTable, job: JobTable::Job, pid_field: String).returns(String) }
|
|
14
|
+
def self.line(table, job, pid_field = '')
|
|
15
|
+
head = "[#{job.number}] #{mark(table, job)} #{pid_field}#{state(job)}"
|
|
16
|
+
"#{head.ljust(33)}#{job.text}"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# + marks the current job, - the previous one (newest-first order).
|
|
20
|
+
sig { params(table: JobTable, job: JobTable::Job).returns(String) }
|
|
21
|
+
def self.mark(table, job)
|
|
22
|
+
index = T.must(table.ordered.index(job))
|
|
23
|
+
['+', '-'].fetch(index, ' ')
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# The state column (dash's statusfmt vocabulary): Running, a strsignal
|
|
27
|
+
# Stopped flavour, Done/Done(n), or the killing signal's description.
|
|
28
|
+
sig { params(job: JobTable::Job).returns(String) }
|
|
29
|
+
def self.state(job)
|
|
30
|
+
return 'Running' if job.running?
|
|
31
|
+
return Signals.stop_description(T.must(job.stopsig)) if job.stopped?
|
|
32
|
+
|
|
33
|
+
settled(job.status)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
sig { params(status: Status).returns(String) }
|
|
37
|
+
def self.settled(status)
|
|
38
|
+
signal = status.termsig
|
|
39
|
+
return Signals.description(signal) if signal
|
|
40
|
+
|
|
41
|
+
code = status.exitstatus
|
|
42
|
+
code.zero? ? 'Done' : "Done(#{code})"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Rush
|
|
5
|
+
# Resolves a %job_id operand (POSIX 2.12 Job Control Job ID) against the
|
|
6
|
+
# job table: % / %% / %+ name the current job, %- the previous one, %n job
|
|
7
|
+
# number n. dash keeps no command text away from a tty, so %string and
|
|
8
|
+
# %?string prefixes match nothing here — No such job, exactly like the
|
|
9
|
+
# oracle. A failed resolution raises JobError carrying dash's message; the
|
|
10
|
+
# consuming builtin prefixes its name and fails with status 2.
|
|
11
|
+
class JobSpec
|
|
12
|
+
extend T::Sig
|
|
13
|
+
|
|
14
|
+
NUMBER = /\A%\d+\z/
|
|
15
|
+
|
|
16
|
+
# Fork-inherited display copies resolve for nobody: the jobs listing
|
|
17
|
+
# shows them (POSIX 2.12 duplicate), but wait/kill/fg/bg in a forked
|
|
18
|
+
# child answer No such job, exactly like dash (probed: a pipeline
|
|
19
|
+
# stage lists [1] while `wait %1` in the same stage reports rc=2).
|
|
20
|
+
sig { params(jobs: JobTable, spec: String).returns(JobTable::Job) }
|
|
21
|
+
def self.resolve(jobs, spec)
|
|
22
|
+
case spec
|
|
23
|
+
when '%', '%%', '%+' then live(jobs.current) || missing('current')
|
|
24
|
+
when '%-' then live(jobs.previous) || missing('previous')
|
|
25
|
+
else by_number(jobs, spec)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
sig { params(jobs: JobTable, spec: String).returns(JobTable::Job) }
|
|
30
|
+
def self.by_number(jobs, spec)
|
|
31
|
+
unknown(spec) unless spec.match?(NUMBER)
|
|
32
|
+
|
|
33
|
+
live(jobs.numbered(Integer(spec.delete_prefix('%'), 10))) || unknown(spec)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
sig { params(job: T.nilable(JobTable::Job)).returns(T.nilable(JobTable::Job)) }
|
|
37
|
+
def self.live(job)
|
|
38
|
+
job if job && !job.inherited?
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
sig { params(which: String).returns(T.noreturn) }
|
|
42
|
+
def self.missing(which)
|
|
43
|
+
raise JobError, "No #{which} job"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
sig { params(spec: String).returns(T.noreturn) }
|
|
47
|
+
def self.unknown(spec)
|
|
48
|
+
raise JobError, "No such job: #{spec}"
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Rush
|
|
5
|
+
class JobTable
|
|
6
|
+
# The job-control environment of this shell: the root-shell bit, how
|
|
7
|
+
# this shell treats stopped children (bare waits, engaged monitor
|
|
8
|
+
# machinery, or a stage's stop relay), and — with a tty — the acquired
|
|
9
|
+
# controlling terminal. dash's rootshell/jobctl guards and
|
|
10
|
+
# ttyfd/initialpgrp globals; monitor and terminal drop together when a
|
|
11
|
+
# forked child clears its job table, while an armed relay survives the
|
|
12
|
+
# fork so stops keep bubbling (rush-l4o).
|
|
13
|
+
class Control
|
|
14
|
+
extend T::Sig
|
|
15
|
+
|
|
16
|
+
sig { returns(T::Boolean) }
|
|
17
|
+
attr_reader :root
|
|
18
|
+
|
|
19
|
+
sig { returns(T.nilable(Terminal)) }
|
|
20
|
+
attr_reader :terminal
|
|
21
|
+
|
|
22
|
+
sig { void }
|
|
23
|
+
def initialize
|
|
24
|
+
@root = T.let(true, T::Boolean)
|
|
25
|
+
@stops = T.let(:default, Symbol)
|
|
26
|
+
@terminal = T.let(nil, T.nilable(Terminal))
|
|
27
|
+
@warning = T.let(0, Integer)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Monitor machinery engaged in this shell (grouping, WUNTRACED waits,
|
|
31
|
+
# stops adopted into the job table).
|
|
32
|
+
sig { returns(T::Boolean) }
|
|
33
|
+
def monitor?
|
|
34
|
+
@stops == :monitor
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# A pipeline stage forked from a monitor-mode shell is a transparent
|
|
38
|
+
# member of its job: once armed, a stop this process's own foreground
|
|
39
|
+
# wait reaps is re-raised onto the process itself, propagating the
|
|
40
|
+
# stop to the job's owner (rush-l4o; dash reaches the same picture by
|
|
41
|
+
# exec-ing simple stages in place). Armed child-side before the
|
|
42
|
+
# subshell reset, while the parent's monitor bit is still readable.
|
|
43
|
+
sig { void }
|
|
44
|
+
def arm_stage_relay
|
|
45
|
+
@stops = :relay if monitor?
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
sig { returns(T::Boolean) }
|
|
49
|
+
def relay?
|
|
50
|
+
@stops == :relay
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Stops are visible to this shell's waits: the monitor adopts them,
|
|
54
|
+
# the relay re-raises them.
|
|
55
|
+
sig { returns(T::Boolean) }
|
|
56
|
+
def stoppable_waits?
|
|
57
|
+
monitor? || relay?
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# `set -m` accepted in the root shell: machinery on, holding the
|
|
61
|
+
# terminal when one was acquired (nil off-tty — grouping still runs).
|
|
62
|
+
sig { params(terminal: T.nilable(Terminal)).void }
|
|
63
|
+
def engage(terminal)
|
|
64
|
+
@stops = :monitor
|
|
65
|
+
@terminal = terminal
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
sig { void }
|
|
69
|
+
def release
|
|
70
|
+
@stops = :default
|
|
71
|
+
@terminal = nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# A fork drops root, terminal and monitor together — but keeps an
|
|
75
|
+
# armed relay, so stops bubble out of nested forks within a stage.
|
|
76
|
+
sig { void }
|
|
77
|
+
def fork_child
|
|
78
|
+
@root = false
|
|
79
|
+
@terminal = nil
|
|
80
|
+
@stops = :default if monitor?
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# dash's job_warning: the first exit with a stopped job warns and is
|
|
84
|
+
# refused; the window it opens lets the very next command's exit
|
|
85
|
+
# through, and two interactive ticks re-arm it (a batch shell never
|
|
86
|
+
# ticks, so it warns exactly once).
|
|
87
|
+
sig { returns(T::Boolean) }
|
|
88
|
+
def warn_exit?
|
|
89
|
+
return false if @warning.positive?
|
|
90
|
+
|
|
91
|
+
@warning = 2
|
|
92
|
+
true
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# One interactive turn passing (the Repl, as dash's cmdloop decrements
|
|
96
|
+
# job_warning per iteration).
|
|
97
|
+
sig { void }
|
|
98
|
+
def tick_warning
|
|
99
|
+
@warning -= 1 if @warning.positive?
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Rush
|
|
5
|
+
# Reopens the table only to keep its interruptible-wait implementation next
|
|
6
|
+
# to the polling collaborator; the core initializer owns these ivars.
|
|
7
|
+
# :reek:InstanceVariableAssumption
|
|
8
|
+
class JobTable
|
|
9
|
+
# WNOHANG polling used only by the wait builtin. Foreground awaits remain
|
|
10
|
+
# blocking, while a caught trap can stop this wait without reaping its job.
|
|
11
|
+
class InterruptibleWait
|
|
12
|
+
extend T::Sig
|
|
13
|
+
|
|
14
|
+
POLL_INTERVAL = 0.01
|
|
15
|
+
|
|
16
|
+
sig do
|
|
17
|
+
params(system: SystemCalls, control: Control, target: Integer,
|
|
18
|
+
pending: T.proc.returns(T.nilable(Integer))).void
|
|
19
|
+
end
|
|
20
|
+
def initialize(system, control, target, pending)
|
|
21
|
+
@system = system
|
|
22
|
+
@control = control
|
|
23
|
+
@target = target
|
|
24
|
+
@pending = pending
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
sig { returns(T.any(Process::Status, Status)) }
|
|
28
|
+
def call
|
|
29
|
+
loop do
|
|
30
|
+
result = interrupted || poll
|
|
31
|
+
return result if result
|
|
32
|
+
|
|
33
|
+
sleep(POLL_INTERVAL)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
sig { returns(T.nilable(Status)) }
|
|
40
|
+
def interrupted
|
|
41
|
+
code = @pending.call
|
|
42
|
+
Status.new(code) if code
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
sig { returns(T.nilable(Process::Status)) }
|
|
46
|
+
def poll
|
|
47
|
+
pair = @control.stoppable_waits? ? @system.poll_pid_stopped(@target) : @system.poll_pid(@target)
|
|
48
|
+
pair&.fetch(1)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The wait builtin alone is interruptible by a caught signal. Polling keeps
|
|
53
|
+
# JobTable the sole reaper while foreground await remains a blocking wait.
|
|
54
|
+
sig { params(pid: Integer, pending: T.proc.returns(T.nilable(Integer))).returns(T.nilable(Status)) }
|
|
55
|
+
def wait_for_interruptibly(pid, &pending)
|
|
56
|
+
@jobs[pid]&.harvest { interruptible_settle(pid, pending) }
|
|
57
|
+
rescue Errno::ECHILD
|
|
58
|
+
Status.success
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
sig do
|
|
64
|
+
params(pid: Integer, pending: T.proc.returns(T.nilable(Integer))).returns(T.any(Process::Status, Status))
|
|
65
|
+
end
|
|
66
|
+
def interruptible_settle(pid, pending)
|
|
67
|
+
@stash.delete(pid) || InterruptibleWait.new(@system, @control, pid, pending).call
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|