Class: Copse::Procfile
- Inherits:
-
Object
- Object
- Copse::Procfile
- Defined in:
- lib/copse/procfile.rb
Overview
Reads a Procfile and splits it into the foreground web process and the rest.
Parsing only. The lifecycle -- spawning, teardown, signal handling -- lives in Session, so everything here is a pure function that can be tested without a process tree.
Defined Under Namespace
Classes: Entry
Constant Summary collapse
- WEB =
"web"- FIXABLE_SEPARATORS =
Control operators that make Ruby's Process.spawn hand the string to /bin/sh, which then becomes the pid foreman records.
[";", "&&", "||"].freeze
- UNFIXABLE_SEPARATORS =
Shapes no
execplacement can make signal-transparent.A pipeline or a background
&keeps a shell waiting on the whole thing. A command substitution or subshell is worse: splicingexecbefore the last operator would put it inside the parentheses, so the outer command is never exec'd at all -- andexec (cd x && y)is not even valid shell syntax, so there is no prefix form to fall back to. Refusing to rewrite what cannot be reasoned about beats producing something subtly wrong. ["|", "&"].freeze
- UNFIXABLE_GROUPINGS =
["$(", "`", "("].freeze
- UNFIXABLE =
(UNFIXABLE_SEPARATORS + UNFIXABLE_GROUPINGS).freeze
- SHELL_REQUIRED =
The characters Ruby itself treats as requiring a shell (mirrors rb_exec_fillarg). A command containing any of these is spawned via
/bin/sh -ceven when it is a single command. /[*?{}\[\]<>()~&|\\$;'"`\n#]/.freeze
- PORT_FLAG =
Matches an explicit port flag on a command line:
-p 3000,--port 3000, or--port=3000. /\s+(?:-p|--port)(?:=|\s+)\d+\b/.freeze
- ASSIGNMENT_PREFIX =
execcannot take a leading VAR=value assignment:exec FOO=1 cmdmakes the shell look for a program literally namedFOO=1and fail with exit 127. Going through env(1) preserves the assignment and still replaces the shell, so the recorded pid is the real process either way. /\A[A-Za-z_][A-Za-z0-9_]*=/.freeze
- TAILWIND_BARE_WATCH =
A bare
--watchon the Tailwind CLI, which is fatal under foreman.tailwindcss --watchexits when stdin closes. Overmind gives every process a pty so stdin stays open; foreman does not, so the watcher exits 0 a second or two after boot and foreman's cascade takeswebdown with it. The zero status makes it read like an intentional shutdown with nothing linking it to CSS, which is why this is worth a line of output.--watch=alwaysis the fix. /\btailwindcss\b/.freeze
- BARE_WATCH_FLAG =
/--watch(?![=\w])/.freeze
Instance Attribute Summary collapse
-
#entries ⇒ Object
readonly
Returns the value of attribute entries.
Class Method Summary collapse
- .exec_prefixed(command) ⇒ Object
- .load(path) ⇒ Object
- .parse(text) ⇒ Object
- .port_flag?(command) ⇒ Boolean
-
.redirect_ampersand?(command, index) ⇒ Boolean
True when the
&atindexbelongs to a redirect rather than being a control operator:2>&1and>&2have>(or<) before it,&>filehas>after it. -
.signal_transparent(command) ⇒ Object
Rewrites a command so the process foreman records is the real one, not a
/bin/shthat will not forward SIGTERM. - .stdin_sensitive_warning(command) ⇒ Object
-
.strip_port_flag(command) ⇒ Object
Removes an explicit port flag from a command.
-
.top_level_operators(command) ⇒ Object
Finds control operators outside quotes.
- .unfixable_warning(_command, operators) ⇒ Object
Instance Method Summary collapse
-
#initialize(entries) ⇒ Procfile
constructor
A new instance of Procfile.
-
#secondaries ⇒ Object
Everything that is not
web. -
#web ⇒ Object
The
webentry, matched by name.
Constructor Details
#initialize(entries) ⇒ Procfile
Returns a new instance of Procfile.
41 42 43 |
# File 'lib/copse/procfile.rb', line 41 def initialize(entries) @entries = entries end |
Instance Attribute Details
#entries ⇒ Object (readonly)
Returns the value of attribute entries.
39 40 41 |
# File 'lib/copse/procfile.rb', line 39 def entries @entries end |
Class Method Details
.exec_prefixed(command) ⇒ Object
139 140 141 142 143 |
# File 'lib/copse/procfile.rb', line 139 def self.exec_prefixed(command) return "exec env #{command}" if command.match?(ASSIGNMENT_PREFIX) "exec #{command}" end |
.load(path) ⇒ Object
61 62 63 |
# File 'lib/copse/procfile.rb', line 61 def self.load(path) File.exist?(path) ? parse(File.read(path)) : nil end |
.parse(text) ⇒ Object
45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 |
# File 'lib/copse/procfile.rb', line 45 def self.parse(text) entries = text.to_s.lines.filter_map do |line| stripped = line.strip next if stripped.empty? || stripped.start_with?("#") name, command = stripped.split(":", 2) next if command.nil? command = command.strip next if command.empty? Entry.new(name: name.strip, command: command) end new(entries) end |
.port_flag?(command) ⇒ Boolean
87 88 89 |
# File 'lib/copse/procfile.rb', line 87 def self.port_flag?(command) command.match?(PORT_FLAG) end |
.redirect_ampersand?(command, index) ⇒ Boolean
True when the & at index belongs to a redirect rather than being a
control operator: 2>&1 and >&2 have > (or <) before it, &>file has
> after it.
213 214 215 216 217 218 |
# File 'lib/copse/procfile.rb', line 213 def self.redirect_ampersand?(command, index) return true if command[index + 1] == ">" before = command[0, index].rstrip before.end_with?(">", "<") end |
.signal_transparent(command) ⇒ Object
Rewrites a command so the process foreman records is the real one, not a
/bin/sh that will not forward SIGTERM.
Returns [rewritten_command, warning_or_nil].
Three nearby forms are wrong and were measured wrong:
`exec sh -c "a; b"` -> recorded pid is still sh; the orphan survives
`exec a; b` -> exec replaces the shell with `a`; `b` never runs
`a | exec b` -> the recorded pid is the shell awaiting the pipeline
What works is inserting exec before the final command of a chain.
A pipeline or a background & cannot be collapsed into one pid by any exec
placement, so those are left alone and warned about instead of silently
"fixed".
105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 |
# File 'lib/copse/procfile.rb', line 105 def self.signal_transparent(command) # A trailing separator would otherwise splice a bare `exec ` with nothing # after it -- a silent no-op that leaves the shell in front of the process. command = command.sub(/[\s;]+\z/, "") operators = top_level_operators(command) if operators.any? { |op| UNFIXABLE.include?(op[:token]) } return [command, unfixable_warning(command, operators)] end return [command, nil] if operators.empty? && !command.match?(SHELL_REQUIRED) # A single command that still needs a shell (a redirect, a glob, a quoted # argument): prefixing exec replaces the shell with it, which is correct. return [exec_prefixed(command), nil] if operators.empty? # A chain: exec the last command. A Procfile entry whose final command is # short-lived would exit immediately and stop being a long-running process # at all, so "the process we care about is last" is forced by what a # Procfile entry is, not merely a convention. last = operators.last cut = last[:at] + last[:token].length head = command[0, cut] tail = command[cut..].to_s indent = tail[/\A\s*/] ["#{head}#{indent}#{exec_prefixed(tail.lstrip)}", nil] end |
.stdin_sensitive_warning(command) ⇒ Object
231 232 233 234 235 236 237 |
# File 'lib/copse/procfile.rb', line 231 def self.stdin_sensitive_warning(command) return nil unless command.match?(TAILWIND_BARE_WATCH) && command.match?(BARE_WATCH_FLAG) "runs the tailwindcss CLI with a bare `--watch`, which exits when stdin closes. " \ "Under foreman that ends the whole session a moment after boot, with exit status 0. " \ "Use `--watch=always`." end |
.strip_port_flag(command) ⇒ Object
Removes an explicit port flag from a command.
An explicit --port beats the PORT environment variable in rails server,
so a Procfile shipping web: bin/rails s --port 3000 -- which vite_ruby's
own example does -- would silently boot on 3000 and defeat the derived
port. Stripping the flag is what makes the stock template work unchanged.
83 84 85 |
# File 'lib/copse/procfile.rb', line 83 def self.strip_port_flag(command) command.gsub(PORT_FLAG, "") end |
.top_level_operators(command) ⇒ Object
Finds control operators outside quotes.
Quote-awareness matters: bin/rails runner 'A.watch; B.watch' is a single
command with a quoted semicolon, not a chain, and treating it as one would
rewrite a working line.
150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 |
# File 'lib/copse/procfile.rb', line 150 def self.top_level_operators(command) operators = [] in_single = false in_double = false index = 0 while index < command.length char = command[index] if in_single in_single = false if char == "'" elsif in_double if char == "\\" index += 1 elsif char == '"' in_double = false end else case char when "'" then in_single = true when '"' then in_double = true when "\\" then index += 1 when "`" then operators << { token: "`", at: index } when "$" if command[index + 1] == "(" operators << { token: "$(", at: index } index += 1 end when "(" operators << { token: "(", at: index } when ";", "\n" then operators << { token: ";", at: index } when "&" if command[index + 1] == "&" operators << { token: "&&", at: index } index += 1 elsif redirect_ampersand?(command, index) # Part of a redirect (`2>&1`, `>&2`, `&>file`), not a control # operator. Reading it as a background `&` made a perfectly ordinary # `yarn build --watch 2>&1` skip the exec transform and keep a shell # in front of the process -- a real orphan on any /bin/sh that forks. nil else operators << { token: "&", at: index } end when "|" if command[index + 1] == "|" operators << { token: "||", at: index } index += 1 else operators << { token: "|", at: index } end end end index += 1 end operators end |
.unfixable_warning(_command, operators) ⇒ Object
239 240 241 242 243 244 245 246 247 248 249 250 251 252 |
# File 'lib/copse/procfile.rb', line 239 def self.unfixable_warning(_command, operators) tokens = operators.map { |op| op[:token] } # Groupings are named before separators: a pipe inside `$( )` is incidental, # and the substitution is the reason the line cannot be rewritten. kind = if tokens.include?("$(") || tokens.include?("`") then "a command substitution" elsif tokens.include?("(") then "a subshell" elsif tokens.include?("|") then "a pipeline" else "a background &" end "uses #{kind}, which keeps a shell in front of it -- its child processes may " \ "survive teardown. Consider splitting it into separate Procfile entries." end |
Instance Method Details
#secondaries ⇒ Object
Everything that is not web. These go to foreman.
73 74 75 |
# File 'lib/copse/procfile.rb', line 73 def secondaries entries.reject { |entry| entry.name == WEB } end |
#web ⇒ Object
The web entry, matched by name. Real templates ship
web: env RUBY_DEBUG_OPEN=true bin/rails server, so never assume a bare
bin/rails server.
68 69 70 |
# File 'lib/copse/procfile.rb', line 68 def web entries.find { |entry| entry.name == WEB } end |