Class: Copse::Session

Inherits:
Object
  • Object
show all
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

Instance Method Summary collapse

Constructor Details

#initialize(worktree, root: nil, out: $stdout) ⇒ Session

Returns a new instance of Session.



29
30
31
32
33
# File 'lib/copse/session.rb', line 29

def initialize(worktree, root: nil, out: $stdout)
  @worktree = worktree
  @root = File.expand_path(root || worktree.root)
  @out = out
end

Instance Attribute Details

#rootObject (readonly)

Returns the value of attribute root.



27
28
29
# File 'lib/copse/session.rb', line 27

def root
  @root
end

#worktreeObject (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_overridesObject

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_envObject

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.

Returns:

  • (Boolean)


505
506
507
508
# File 'lib/copse/session.rb', line 505

def foreman_available?
  foreman_version
  @foreman_probe[:ok]
end

#foreman_envObject

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_candidatesObject

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_messageObject

One clear line, never a backtrace.



536
537
538
539
540
541
542
# File 'lib/copse/session.rb', line 536

def foreman_error_message
  "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

Returns:

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



375
376
377
# File 'lib/copse/session.rb', line 375

def foreman_port_env
  { "PORT" => (worktree.port + FOREMAN_PORT_OFFSET).to_s }
end

#foreman_versionObject



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_warningObject



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.

Returns:

  • (Boolean)


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_envObject

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_warningObject



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_warningObject



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

#procfileObject



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_pathObject



248
# File 'lib/copse/session.rb', line 248

def procfile_path = File.join(root, "Procfile.dev")

#secondariesObject

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

Returns:

  • (Boolean)


446
447
448
# File 'lib/copse/session.rb', line 446

def secondaries?
  secondaries.any?
end

#startObject

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 foreman_error_message
      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_commandObject

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_envObject

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.

Returns:

  • (Boolean)


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.

Returns:

  • (Boolean)


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_procfileObject

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