rails

Building a better bin/dev script

Rails 7.2 uses the foreman gem for process management, which has serious limitations when it comes to interactive debugging. The run-pty Node package is an excellent alternative.

Rails needs multiple processes

Modern Rails apps often need to run multiple processes for local development. These processes include frontend watchers such as:

yarn build --watch      # prescribed by jsbundling-rails
npx tailwindcss --watch # for just-in-time Tailwind compilation
bin/vite dev            # Vite Ruby's hot-reloading dev server

Job processors like these:

bin/sidekiq
bin/rails solid_queue:start

And of course, the Rails process itself:

bin/rails server

The problem with bin/dev: debugging

Rails provides a script to start all of the necessary processes: bin/dev.1 Inside, it uses the foreman gem to start processes declared in a Procfile.dev file:

#!/usr/bin/env sh

if gem list --no-installed --exact --silent foreman; then
  echo "Installing foreman..."
  gem install foreman
fi

exec foreman start -f Procfile.dev --env /dev/null "$@"

Foreman runs all the processes concurrently and merges their output into one big stream of data. This is great for keeping a birds-eye view of an application, but what if I want to interact with one of these processes in the console?

Opaque console input

Right now, interactive debuggers in foreman are borderline unusable. Here’s what happens when I add a breakpoint to my Rails controller with binding.irb to start an interactive debugging session:

13:38:24 web.1  | From: app/controllers/pages_controller.rb @ line 7 :
13:38:24 web.1  |
13:38:24 web.1  |     2:
13:38:24 web.1  |     3: class PagesController < ApplicationController
13:38:24 web.1  |     4:   include AllowPublicCaching
13:38:24 web.1  |     5:
13:38:24 web.1  |     6:   def home
13:38:24 web.1  |  => 7:     binding.irb
13:38:24 web.1  |     8:   end
13:38:24 web.1  |     9: end
13:38:24 web.1  |
13:38:24 vite.1 | yarn run v1.22.22
13:38:24 vite.1 | $ node_modules/.bin/vite --mode development
13:38:25 vite.1 |
13:38:25 vite.1 |   VITE v5.3.5  ready in 112 ms
13:38:25 vite.1 |
13:38:25 vite.1 |   ➜  Local:   https://localhost:3036/vite-dev/
13:38:25 vite.1 |   ➜  press h + enter to show help

There’s no prompt that indicates IRB is ready to accept my input. Furthermore, when I type, my keystrokes are not echoed, so it is not clear that the debugger is even listening. (In fact, foreman is sending my input to the Rails process, so it is technically possible to issue commands to IRB, but the experience is frustrating to say the least.)

Jumbled output

Even if the console input problem can be solved, foreman still merges and streams the output for all processes at once. Using an interactive debugger for the Rails process while, say, the Sidekiq process is simultaneously writing to the same console is not ideal.

Solution: run-pty instead of foreman

Foreman is the de facto standard process runner in the Ruby community, but Node provides a lot of the tools we use in modern Rails apps, and the JavaScript community seems to be pretty far along in building advanced solutions for these problems. The concurrently package, for example, is a popular multi-processes runner that allows stdin to be connected to an arbitrary process using the --default-input-target option.

Introducing run-pty

However, it’s the run-pty package that has really caught my attention. It creates a pseudoterminal (pty) for each process, and provides the ability to switch between them at any time using keyboard shortcuts or mouse clicks. It’s like tmux without the learning curve! Here’s what it looks like when run-pty starts my Rails app consisting of 3 processes:

$ bin/dev
[1]  🟒  bin/rails server
[2]  🟒  bin/vite dev
[3]  🟒  bin/sidekiq

[1-3]    focus command (or click)
[ctrl+c] kill all
[↑↓←→]   move selection

The process output isn’t interleaved; instead, I can choose which process to see from the menu. Pressing “2” (or clicking on it) shows the Vite output, for example.

  VITE v5.3.4  ready in 231 ms

  ➜  Local:   http://localhost:3036/vite-dev/
  ➜  press h + enter to show help

[ctrl+c] kill (pid 29161)
[ctrl+z] dashboard

In addition to seeing Vite’s output, stdin is now exclusively connected to Vite, so I can interact with it, e.g. by pressing h and enter to see help:

  VITE v5.3.4  ready in 231 ms

  ➜  Local:   http://localhost:3036/vite-dev/
  ➜  press h + enter to show help
h

  Shortcuts
  press r + enter to restart the server
  press u + enter to show server url
  press o + enter to open in browser
  press c + enter to clear console
  press q + enter to quit

[ctrl+c] kill (pid 29161)
[ctrl+z] dashboard

Nice! I didn’t even know Vite had interactive console commands.

Entering the debugger REPL

More importantly, when it comes time to debug Rails or Sidekiq, I can choose that process from the run-pty menu, giving me full console access to the debugger with the familiar IRB prompt. This includes all the bells and whistles like ANSI color, auto-completions, and readline history.

From: app/controllers/pages_controller.rb @ line 7 :

    2:
    3: class PagesController < ApplicationController
    4:   include AllowPublicCaching
    5:
    6:   def home
 => 7:     binding.irb
    8:   end
    9: end

irb(#<PagesController:0x000000012...):001>
[ctrl+c] kill (pid 34488)
[ctrl+z] dashboard

And at any time, I can press ctrl-z to go back to the process menu.

Installation

My Rails app was already using Node with a package.json and yarn.lock, so to install run-pty, I ran:

yarn add -D run-pty

Instead of Procfile.dev, run-pty uses a JSON file. By convention, it is named run-pty.json. A basic config looks like this:

[
  {
    "command": ["bin/rails", "server"]
  },
  {
    "command": ["bin/vite", "dev"]
  },
  {
    "command": ["bin/sidekiq"]
  }
]

My config, using some optional settings, is a bit fancier:

[
  {
    "command": ["bin/rails", "server"],
    "status": {
      "Listening on": null
    },
    "defaultStatus": ["⏳", "S"]
  },
  {
    "command": ["bin/vite", "dev"],
    "status": {
      "ready in": null
    },
    "defaultStatus": ["⏳", "S"]
  },
  {
    "command": ["bin/sidekiq"],
    "status": {
      "Starting processing": null
    },
    "defaultStatus": ["⏳", "S"]
  }
]

The status and defaultStatus fields customize the icon that appears next to each process. I’m using them to show a loading indicator while the process starts. For example, bin/rails starts with the ⏳ icon, and when the string “Listening on” appears in the output (meaning Puma is ready for connections), the icon changes to 🟒. Here’s a quick demo:

Screen recording of run-pty starting

Finally, my updated bin/dev script now looks like this:

#!/usr/bin/env ruby
exec "npx", "run-pty", "run-pty.json", *ARGV

Next steps

The Rails and Foreman developers are aware of the current bin/dev limitations and are working on a fix or replacement in time for the next major release, Rails 8.0. In the meantime, try run-pty and let me know what you think!

And, for creating your next Rails app, check out my nextgen Rails application generator. If you choose Vite for the frontend build system, a run-pty version of the bin/dev script is included out of the box.


  1. The foreman-based bin/dev script is automatically provided by rails new when a Node-based JavaScript or CSS option is specified, e.g. --javascript=esbuild. The script is installed by jsbundling-rails or cssbundling-rails, respectively. 

Share this? Copy link

Feedback? Email me!

Hi! πŸ‘‹ I’m Matt Brictson, a software engineer in San Francisco. This site is my excuse to practice UI design, fuss over CSS, and share my interest in open source. I blog about Rails, design patterns, and other development topics.

Recent articles

RSS
View all posts β†’

Open source projects

mattbrictson/bundle_update_interactive

A stylish interactive mode for Bundler, inspired by yarn upgrade-interactive

216
Updated 24 days ago

mattbrictson/tomo

A friendly and extensible CLI for deploying Rails apps via SSH

418
Updated 24 days ago

More on GitHub β†’