Class: Copse::Procfile

Inherits:
Object
  • Object
show all
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 exec placement can make signal-transparent.

A pipeline or a background & keeps a shell waiting on the whole thing. A command substitution or subshell is worse: splicing exec before the last operator would put it inside the parentheses, so the outer command is never exec'd at all -- and exec (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 -c even 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 =

exec cannot take a leading VAR=value assignment: exec FOO=1 cmd makes the shell look for a program literally named FOO=1 and 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 --watch on the Tailwind CLI, which is fatal under foreman.

tailwindcss --watch exits 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 takes web down 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=always is the fix.

/\btailwindcss\b/.freeze
BARE_WATCH_FLAG =
/--watch(?![=\w])/.freeze

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

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

#entriesObject (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

Returns:

  • (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.

Returns:

  • (Boolean)


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

#secondariesObject

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

#webObject

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