Class: Copse::Session
- Inherits:
-
Object
- Object
- Copse::Session
- Defined in:
- lib/copse/session.rb
Overview
Everything the session needs before it spawns anything: the environment, a safe temporary Procfile, and a trustworthy answer to whether foreman works.
Constant Summary collapse
- MIN_FOREMAN_VERSION =
"0.90.0"- FOREMAN_STARTUP_GRACE =
How long to wait before checking whether foreman died on the spot. Long enough to catch a missing binary or an empty Procfile, short enough to be invisible next to a Rails boot.
0.3- REAP_TIMEOUT =
How long teardown waits for a child to honour SIGTERM before SIGKILL. Chosen to sit just past foreman's own 5s escalation so its children get their graceful window first.
6- REAP_POLL =
0.05- INTERRUPTED_STATUS =
130- FOREMAN_PORT_OFFSET =
Foreman derives each child's port as
base_port + index * 100, and takes base_port from PORT. Handing it the derived port therefore gave the first secondary exactly the web process's port -- so a secondary that binds it collides with puma, and copse's own collision message then blames another worktree.Offsetting the base is the least-bad of three options. Removing PORT entirely looks cleaner but is worse: foreman then falls back to 5000, which is the macOS AirPlay Receiver port and is on our own reserved list. Secondaries that need the app's real port read COPSE_PORT, which foreman does not touch.
100
Instance Attribute Summary collapse
-
#root ⇒ Object
readonly
Returns the value of attribute root.
-
#worktree ⇒ Object
readonly
Returns the value of attribute worktree.
Instance Method Summary collapse
-
#bundler_overrides ⇒ Object
Process.spawn merges its env hash rather than replacing the environment, so handing it Bundler.original_env is not enough: the inherited BUNDLE_* keys survive the merge.
-
#copse_env ⇒ Object
The variables every process Copse starts receives.
-
#exec_overmind(args = []) ⇒ Object
Hands the whole Procfile --
webincluded -- to Overmind, replacing this process. -
#foreman_available? ⇒ Boolean
Whether
foreman startwill actually work. -
#foreman_env ⇒ Object
The environment foreman is actually spawned with -- whichever candidate the probe found works.
-
#foreman_env_candidates ⇒ Object
Two ways foreman can be reachable, and neither one covers both.
-
#foreman_error_message ⇒ Object
One clear line, never a backtrace.
- #foreman_outdated? ⇒ Boolean
- #foreman_port_env ⇒ Object
- #foreman_version ⇒ Object
- #foreman_version_warning ⇒ Object
-
#initialize(worktree, root: nil, out: $stdout) ⇒ Session
constructor
A new instance of Session.
-
#overmind_available? ⇒ Boolean
Whether
overmind startwill actually work. -
#overmind_env ⇒ Object
OVERMIND_SKIP_ENV is this path's
--env /dev/null, and for the same reason: Overmind loads the app's.envand applies it over the environment it was handed, so anything Copse derives is otherwise beatable by a stale.env. - #overmind_port_flag_warning ⇒ Object
- #overmind_web_position_warning ⇒ Object
- #procfile ⇒ Object
- #procfile_path ⇒ Object
-
#secondaries ⇒ Object
Non-web entries.
- #secondaries? ⇒ Boolean
-
#start ⇒ Object
Boots the app and returns the foreground process's exit status.
-
#web_command ⇒ Object
The foreground command, with any explicit port flag removed so the derived PORT applies.
-
#web_env ⇒ Object
The foreground process keeps the inherited bundler environment: it is the Rails app and needs its bundle.
-
#web_out_of_position? ⇒ Boolean
Only Overmind cares: it hands
webthe port at its Procfile index, so awebline that is not first getsport + index * 100. -
#web_port_flag? ⇒ Boolean
An explicit
--porton thewebline beats PORT inrails server, and vite_ruby's own example ships one. -
#write_temp_procfile ⇒ Object
Writes the secondaries to a Procfile foreman can run, inside a private directory.
Constructor Details
Instance Attribute Details
#root ⇒ Object (readonly)
Returns the value of attribute root.
27 28 29 |
# File 'lib/copse/session.rb', line 27 def root @root end |
#worktree ⇒ Object (readonly)
Returns the value of attribute worktree.
27 28 29 |
# File 'lib/copse/session.rb', line 27 def worktree @worktree end |
Instance Method Details
#bundler_overrides ⇒ Object
Process.spawn merges its env hash rather than replacing the environment, so handing it Bundler.original_env is not enough: the inherited BUNDLE_* keys survive the merge. Removing them requires explicit nils.
410 411 412 413 414 415 416 417 418 |
# File 'lib/copse/session.rb', line 410 def bundler_overrides return {} unless defined?(Bundler) && Bundler.respond_to?(:original_env) original = Bundler.original_env overrides = {} (ENV.keys - original.keys).each { |key| overrides[key] = nil } original.each { |key, value| overrides[key] = value if ENV[key] != value } overrides end |
#copse_env ⇒ Object
The variables every process Copse starts receives.
COPSE_PORT exists because foreman rewrites PORT for its own children (base_port + index * 100), so a secondary never sees the derived port under the name PORT. COPSE_PORT is the name foreman does not touch. COPSE_DATABASE_SUFFIX is nil in a main worktree, which keeps its plain database name. Nil rather than a missing key on purpose: Process.spawn merges its env hash into the inherited one, so leaving the key out would let a stale value inherited from elsewhere -- a shell opened from a linked worktree's session -- reach a main worktree's children and be believed. Nil is the only way to say "unset this".
346 347 348 349 350 351 352 353 354 355 |
# File 'lib/copse/session.rb', line 346 def copse_env { "PORT" => worktree.port.to_s, "COPSE_PORT" => worktree.port.to_s, "COPSE_HOST" => worktree.host, "COPSE_URL" => worktree.url, "VITE_RUBY_PORT" => worktree.companion_port.to_s, "COPSE_DATABASE_SUFFIX" => worktree.database_suffix } end |
#exec_overmind(args = []) ⇒ Object
Hands the whole Procfile -- web included -- to Overmind, replacing this
process. Never returns.
There is no split session here and no foreground/background distinction to
preserve: Overmind gives every process its own tmux pty, so binding.irb
works over overmind connect web rather than by holding this terminal. All
Copse contributes on this path is the environment.
The environment is copse_env unoffset -- deliberately not
foreman_port_env. Overmind derives each child's port as
base + index * 100 from PORT just as foreman does, but here it supervises
web too, so offsetting the base would hand web a port Copse never
derived and the banner above would name the wrong URL.
-p before the caller's own arguments, so bin/dev -p 4000 still wins.
The base port is passed as a flag rather than left to the inherited PORT
because only the flag survives Overmind's env files. Measured against
Overmind 2.5.1: with PORT in .overmind.env and the derived port merely
inherited, the app booted on the env file's port; with -p it booted on the
derived one. Secondaries still get base + index * 100.
270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 |
# File 'lib/copse/session.rb', line 270 def exec_overmind(args = []) @out.puts "=> Copse: #{worktree.url}" @out.puts overmind_web_position_warning if web_out_of_position? @out.puts overmind_port_flag_warning if web_port_flag? # Matches the `chdir: root` both foreman and the foreground web spawn use, but # for a different reason. The processes themselves are fine either way -- # Overmind takes their working directory from the Procfile's own directory, so # `bin/rails server` resolves even when invoked from elsewhere. The *socket* is # not: `.overmind.sock` is created relative to Overmind's own cwd, and # `overmind connect web` from the app root then dials a path that does not # exist. Dir.chdir(root) exec(overmind_env, "overmind", "start", "-f", procfile_path, "-p", worktree.port.to_s, *args) end |
#foreman_available? ⇒ Boolean
Whether foreman start will actually work.
Probing is the only reliable answer. command -v foreman and
File.executable? both succeed on a version-manager shim whose gem is absent
for the active Ruby, and the real invocation then dies with a
Gem::GemNotFoundException backtrace. Capturing stderr is the point: it is
what keeps that backtrace off the developer's screen.
Probes with the same environment the real spawn uses -- otherwise the bundler case passes the probe and fails the run.
505 506 507 508 |
# File 'lib/copse/session.rb', line 505 def foreman_available? foreman_version @foreman_probe[:ok] end |
#foreman_env ⇒ Object
The environment foreman is actually spawned with -- whichever candidate the probe found works. Falls back to the preferred candidate when nothing has been probed yet.
382 383 384 385 |
# File 'lib/copse/session.rb', line 382 def foreman_env foreman_version @foreman_env || foreman_env_candidates.first end |
#foreman_env_candidates ⇒ Object
Two ways foreman can be reachable, and neither one covers both.
Stripping the bundler environment first is what handles the common case:
bin/dev has to load the bundle to require copse at all, so foreman would
otherwise inherit BUNDLE_GEMFILE and RUBYOPT and die with "foreman is not
currently included in the bundle" even though it is installed. That is what
Rails' own /bin/sh bin/dev achieves by exec'ing foreman outside the bundle.
But if foreman is provided only by the app's Gemfile, the stripped environment is the one that cannot see it. So the inherited environment is the second candidate rather than an alternative design: probing both is what makes foreman-as-a-system-gem and foreman-in-the-Gemfile both work.
399 400 401 402 403 404 405 |
# File 'lib/copse/session.rb', line 399 def foreman_env_candidates base = copse_env.merge(foreman_port_env) overrides = bundler_overrides return [base] if overrides.empty? [base.merge(overrides), base] end |
#foreman_error_message ⇒ Object
One clear line, never a backtrace.
536 537 538 539 540 541 542 |
# File 'lib/copse/session.rb', line 536 def "copse: cannot run `foreman`, which is needed for the #{secondaries.size} non-web " \ "#{secondaries.size == 1 ? 'process' : 'processes'} in Procfile.dev. " \ "Add `foreman` to your Gemfile, or install it for the Ruby you are using " \ "(a version manager keeps gems per Ruby version, so switching versions can " \ "leave foreman behind)." end |
#foreman_outdated? ⇒ Boolean
544 545 546 547 548 549 550 551 |
# File 'lib/copse/session.rb', line 544 def foreman_outdated? version = foreman_version return false if version.nil? Gem::Version.new(version) < Gem::Version.new(MIN_FOREMAN_VERSION) rescue ArgumentError false end |
#foreman_port_env ⇒ Object
375 376 377 |
# File 'lib/copse/session.rb', line 375 def foreman_port_env { "PORT" => (worktree.port + FOREMAN_PORT_OFFSET).to_s } end |
#foreman_version ⇒ Object
510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 |
# File 'lib/copse/session.rb', line 510 def foreman_version return @foreman_probe[:version] if defined?(@foreman_probe) @foreman_probe = { ok: false, version: nil } foreman_env_candidates.each do |candidate| out, _err, status = begin Open3.capture3(candidate, "foreman", "version") rescue Errno::ENOENT, Errno::EACCES # Not on this candidate's PATH, or not executable. Try the next one. next end next unless status.success? # Remember the environment that worked: the real spawn must use the same # one, or the probe proves nothing. @foreman_env = candidate @foreman_probe = { ok: true, version: out.strip } break end @foreman_probe[:version] end |
#foreman_version_warning ⇒ Object
553 554 555 556 |
# File 'lib/copse/session.rb', line 553 def foreman_version_warning "copse: foreman #{foreman_version} is older than #{MIN_FOREMAN_VERSION}; " \ "process teardown is only verified from #{MIN_FOREMAN_VERSION} onward." end |
#overmind_available? ⇒ Boolean
Whether overmind start will actually work. Overmind is a Go binary rather
than a gem, so unlike foreman there is no bundler environment to strip and no
version-manager shim to see past -- but probing still beats command -v,
which succeeds on an unexecutable file.
--version, not version: Overmind has no version subcommand and exits 3
on one, which would make every probe fail and silently downgrade the whole
path to foreman.
328 329 330 331 332 333 |
# File 'lib/copse/session.rb', line 328 def overmind_available? _out, _err, status = Open3.capture3("overmind", "--version") status.success? rescue Errno::ENOENT, Errno::EACCES false end |
#overmind_env ⇒ Object
OVERMIND_SKIP_ENV is this path's --env /dev/null, and for the same reason:
Overmind loads the app's .env and applies it over the environment it was
handed, so anything Copse derives is otherwise beatable by a stale .env.
It is not the port's only defence -- -p above is, and it covers
.overmind.env, which this flag does not skip. What this buys is that both
supervisors see the same environment. That matters more here than it looks:
the overmind bin/dev falls back to the foreman session on a machine without
overmind, so if the two disagreed about .env, one committed repo would
behave differently per teammate.
Only the supervisor's env-file loading is suppressed, not the app's.
dotenv-rails still reads .env inside Rails, exactly as on the foreman path.
316 317 318 |
# File 'lib/copse/session.rb', line 316 def overmind_env copse_env.merge("OVERMIND_SKIP_ENV" => "1") end |
#overmind_port_flag_warning ⇒ Object
298 299 300 301 |
# File 'lib/copse/session.rb', line 298 def overmind_port_flag_warning "copse: the `web` line in Procfile.dev sets an explicit port, which beats the derived " \ "#{worktree.port} -- so the app will not be at #{worktree.url}. Remove the flag." end |
#overmind_web_position_warning ⇒ Object
459 460 461 462 463 464 |
# File 'lib/copse/session.rb', line 459 def overmind_web_position_warning index = procfile.entries.index(procfile.web) "copse: `web` is entry #{index + 1} in Procfile.dev, so overmind will start it on " \ "#{worktree.port + index * 100} rather than the derived port #{worktree.port} " \ "(each process gets base + index * 100). Move `web` to the top of Procfile.dev." end |
#procfile ⇒ Object
242 243 244 245 246 |
# File 'lib/copse/session.rb', line 242 def procfile return @procfile if defined?(@procfile) @procfile = Procfile.load(procfile_path) end |
#procfile_path ⇒ Object
248 |
# File 'lib/copse/session.rb', line 248 def procfile_path = File.join(root, "Procfile.dev") |
#secondaries ⇒ Object
Non-web entries. When a Procfile exists but has no web line, every entry
is a secondary and the foreground falls back to bin/rails server.
440 441 442 443 444 |
# File 'lib/copse/session.rb', line 440 def secondaries return [] if procfile.nil? procfile.web ? procfile.secondaries : procfile.entries end |
#secondaries? ⇒ Boolean
446 447 448 |
# File 'lib/copse/session.rb', line 446 def secondaries? secondaries.any? end |
#start ⇒ Object
Boots the app and returns the foreground process's exit status.
The load-bearing property is that the web process is spawned with this
process's own stdin, stdout, and stderr and stays in the terminal's
foreground process group, so binding.irb and debug behave exactly as
they do under a bare rails server. Nothing may come between the two.
41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 |
# File 'lib/copse/session.rb', line 41 def start @out.puts "=> Copse: #{worktree.url}" # The guard spans everything after the banner, not just the foreground wait. # The foreman probe spawns a child of its own, and a signal arriving during it # used to escape the handler entirely -- which surfaced as a bogus "cannot run # foreman" error rather than a clean interrupt. guarded do # No secondaries means no foreman -- not even a preflight. The Procfile the # install generator writes has only a `web` line, so preflighting a tool # this run will never use would refuse to boot the most common app. Running # `foreman start` against an empty Procfile is fatal anyway. next run_foreground unless secondaries? unless foreman_available? @out.puts next 1 end @out.puts foreman_version_warning if foreman_outdated? @foreman_pid = spawn_foreman(write_temp_procfile) report_if_foreman_died_early run_foreground end ensure teardown end |
#web_command ⇒ Object
The foreground command, with any explicit port flag removed so the derived
PORT applies. Falls back to bin/rails server when there is no Procfile, or
when a Procfile exists but names no web process.
The foreground command goes through the same signal-transparency transform as
the secondaries. Without it, a web line needing a shell (... 2>&1) made
and the real server survived, reparented to pid 1, still holding the derived
port. exec is only added when the command would have gone through /bin/sh
anyway, so the common metacharacter-free case is untouched.
429 430 431 432 433 434 435 436 |
# File 'lib/copse/session.rb', line 429 def web_command entry = procfile&.web return "bin/rails server" if entry.nil? command, warning = Procfile.signal_transparent(Procfile.strip_port_flag(entry.command)) @out.puts "copse: `web` #{warning}" if warning command end |
#web_env ⇒ Object
The foreground process keeps the inherited bundler environment: it is the Rails app and needs its bundle.
359 360 361 |
# File 'lib/copse/session.rb', line 359 def web_env copse_env end |
#web_out_of_position? ⇒ Boolean
Only Overmind cares: it hands web the port at its Procfile index, so a
web line that is not first gets port + index * 100. On the foreman path
Copse spawns web itself with the derived PORT, so its position is
irrelevant.
454 455 456 457 |
# File 'lib/copse/session.rb', line 454 def web_out_of_position? entry = procfile&.web !entry.nil? && procfile.entries.first != entry end |
#web_port_flag? ⇒ Boolean
An explicit --port on the web line beats PORT in rails server, and
vite_ruby's own example ships one. The foreman path strips it, but only
because Copse builds that command itself; here Overmind runs the app's own
Procfile, and rewriting it into a temp copy would mean overmind restart and
the file the developer edits were no longer the same thing. So this is a
warning, the same call the signal-transparency transform makes for shapes it
will not rewrite.
293 294 295 296 |
# File 'lib/copse/session.rb', line 293 def web_port_flag? entry = procfile&.web !entry.nil? && Procfile.port_flag?(entry.command) end |
#write_temp_procfile ⇒ Object
Writes the secondaries to a Procfile foreman can run, inside a private directory. The file's contents are commands foreman will execute, and derived hostnames are deliberately reproducible, so a predictable path in a world-writable directory would be a symlink/TOCTOU surface.
Returns the Procfile path. The directory is recorded on the instance the moment it exists, so teardown can remove it even if a later step in this method raises -- returning it to the caller would have leaked it.
474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 |
# File 'lib/copse/session.rb', line 474 def write_temp_procfile dir = Dir.mktmpdir("copse-") @procfile_dir = dir File.chmod(0o700, dir) path = File.join(dir, "Procfile") lines = secondaries.map do |entry| command, warning = Procfile.signal_transparent(entry.command) @out.puts "copse: `#{entry.name}` #{warning}" if warning stdin_warning = Procfile.stdin_sensitive_warning(entry.command) @out.puts "copse: `#{entry.name}` #{stdin_warning}" if stdin_warning "#{entry.name}: #{command}\n" end File.open(path, File::WRONLY | File::CREAT | File::EXCL, 0o600) do |file| file.write(lines.join) end path end |