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:

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.
-
The foreman-based
bin/dev
script is automatically provided byrails 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. ↩