Stop rbspy and its command when singed is terminated - #84
Merged
Merged
Conversation
Closes #39. Kernel#system left sudo, rbspy and the profiled command running when singed was killed, for instance by a script stopping a backgrounded `singed -- bin/rails server`. Now singed passes SIGTERM on to rbspy as SIGINT, which sudo relays and is the only signal rbspy stops cleanly on: it kills the command and writes the flamegraph, and singed then opens it as usual. Only the first SIGTERM is passed on, because rbspy exits without writing anything when it's interrupted twice, and scripts can send more than one: the issue's runs its cleanup from both its signal and EXIT traps. For the same reason, singed ignores SIGINT while rbspy runs rather than passing it on, since Ctrl-C at a terminal already reaches rbspy. Ignoring it also means Ctrl-C no longer kills singed before it opens the flamegraph, when sudo runs rbspy without a pty.
A nil value in the rbspy options makes a flag, so without `--rate` the
CLI passed rbspy a bare `--rate`, which rbspy rejects:
error: a value is required for '--rate <RATE>' but none was supplied
Only pass it when it's set, so rbspy falls back to its default rate.
Push Gem now releases when singed.gemspec's version changes, so bumping it releases this change once it merges. It's a minor bump because main has gained features since 0.3.0: profiling with vernier, the Sidekiq middleware, explicit start and stop methods and the vendored speedscope, and the minimum Ruby is now 3.3. Gemfile.lock records singed's version, so it changes too; otherwise bundle install would rewrite it and rake release would refuse to release from a dirty tree.
dduugg
force-pushed
the
cli-forward-signals
branch
from
September 29, 2026 22:47
1a3778b to
1764d45
Compare
`#: self as untyped` binds self for the rest of the method, so Sorbet had stopped checking everything in `sudo` after the spawn, including the call to wait_passing_on_signals. Only the spawn is untyped now. trap returns nil for a handler installed outside Ruby, and trap(sig, nil) ignores the signal, so the handlers are restored as DEFAULT then. The spec waited 15 s for each step, which a loaded machine overran, so it waits up to 60 s. Every example now waits for the command's pid rather than for its file, which can exist before the pid is written. rbspy's stand-in traps SIGINT before it starts the command, as the examples signal once the command runs. It also checks that -r is passed on as --rate, and that nothing is opened when rbspy fails. TMPDIR keeps the page the bundled speedscope writes out of the system's temporary directory. The README now says that a kill -INT sent to singed alone is ignored.
sudo before 1.9.13 doesn't relay a signal sent from its own process group, and singed is in that group, so that sudo stays in the terminal's foreground. With Ubuntu 22.04's sudo 1.9.9, singed's SIGINT was dropped, and singed then waited forever, ignoring SIGINT and SIGTERM. Now a kill in a process group of its own sends it. That kill fails while sudo briefly runs entirely as root, as it does starting up, and a later SIGTERM then tries again. sudo-project/sudo@36742de rbspy can also exit within milliseconds of the SIGINT, after which singed restored its SIGTERM handler, so a second SIGTERM, like the one the issue's script sends a few milliseconds after the first, killed singed before it opened the flamegraph. Once rbspy has been interrupted, singed now ignores SIGTERM until it exits. The spec's sudo stand-in now drops signals from its own process group, as sudo 1.9.12 did, and an example sends a SIGTERM while the flamegraph opens. The README says to signal singed alone rather than its process group, since sudo relays a SIGTERM straight to rbspy, which then exits without writing anything, and that rbspy kills only the command itself.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #39.
Problem
singed -- <command>runssudo rbspy record ... -- <command>withKernel#system. When something kills singed, such as a script that backgroundssinged -- bin/rails serverand later runskill, only singed exits. sudo, rbspy and the server keep running without it, and no flamegraph is opened.Passing the signal on unchanged wouldn't help. rbspy only handles SIGINT (
ctrlcwithout itsterminationfeature), so a SIGTERM kills rbspy straight away. It then writes no flamegraph and leaves the command running.Change
While rbspy runs, singed:
Passes the first SIGTERM on as SIGINT. rbspy then stops recording, kills the command and writes the flamegraph. After that, singed carries on as usual, fixing the file's ownership, cleaning it up and opening it.
The SIGINT comes from a
killin a process group of its own. sudo before 1.9.13 (sudo-project/sudo@36742de) doesn't relay a signal sent from its own process group. singed is in that group, so that sudo stays in the terminal's foreground. That covers Ubuntu 22.04's sudo 1.9.9, macOS 13's and RHEL 8's. There, a SIGINT from singed itself was dropped, and singed then waited forever. Ifkillfails, as it does while sudo briefly runs entirely as root at startup, a later SIGTERM tries again.Ignores every SIGTERM after that, until it exits. rbspy treats a second SIGINT as "exit with haste" and writes nothing, and scripts can send SIGTERM more than once. The issue's script is an example: it runs
cleanupfrom both its signal trap and itsEXITtrap, a few milliseconds apart. rbspy can finish within those milliseconds, so the SIGTERMs stay ignored while singed opens the flamegraph too.Ignores SIGINT itself. Ctrl-C at a terminal already reaches rbspy, either directly or through sudo's pty, so passing it on too would interrupt rbspy twice. sudo filters terminal Ctrl-C for the same reason. As a side effect, when sudo runs without a pty, Ctrl-C no longer kills singed before it can open the flamegraph.
So to stop a backgrounded singed, send it SIGTERM, which is
kill's default. Akill -INTsent to singed alone is now ignored. The README's Command Line section explains this, and two limits:kill -- -<pgid>, ortimeoutwithout--foreground, sends SIGTERM to the whole process group. sudo 1.9.13 and later then relay it straight to rbspy, which exits without writing a flamegraph."Leave --rate off rbspy's command line unless it's given" fixes a bug I found while testing. Without
-r, singed passed rbspy a bare--rate, because anilvalue in its options hash marks a flag. rbspy 0.53 rejects that outright (error: a value is required for '--rate <RATE>' but none was supplied), sosinged -- <command>failed immediately unless given a rate."Keep sudo type-checked and harden the CLI spec" narrows
#: self as untyped, which had switched off Sorbet for the rest ofsudo. It also restores a trap handler that Ruby reports asnil, meaning one installed outside Ruby, asDEFAULTrather than ignoring the signal.Release
"Release 0.4.0" bumps the version, so merging this releases it: once CI Test passes on
main, Push Gem (#83) publishes 0.4.0 to RubyGems and creates its GitHub release. It's a minor bump becausemainhas gained features since 0.3.0 (vernier, the Sidekiq middleware, explicit start and stop, vendored speedscope) and now requires Ruby 3.3.Gemfile.lockrecords singed's version too, so it's bumped with the gemspec; otherwisebundle installwould change it andrake releasewould refuse to release from a dirty tree."Name Gusto Engineers as singed's authors" changes the gemspec's
authorsto Gusto Engineers and itsemailto dev@gusto.com, so 0.4.0 ships with them.Testing
spec/singed/cli_spec.rbrunsexe/singedend to end in a subprocess, with stand-ins onPATHfor:It checks that SIGTERM stops rbspy exactly once, the command is killed, and the flamegraph is written and opened. It also checks that:
--rateis passed on;Mutating the code shows the examples catch each fix:
main's CLI, every example fails.The spec starts singed from a clean Bundler environment. Otherwise singed's
Bundler.with_unbundled_envrestoresPATHfrom the inheritedBUNDLER_ORIG_PATH, which drops the stand-ins, and the real sudo would run. GitHub's runners have passwordless sudo. Its waits allow 60 s, since 15 s timed out on a loaded machine.rspecpasses on Ruby 3.3.11, 3.4.11 and 4.0.5, and 15 random-order runs of the CLI spec all passed.srb tcandrubocopare clean.With real sudo and rbspy 0.53.0, in Docker as a non-root user with passwordless sudo:
bashand viasetsid, with and without a SIGTERM to the scriptuse_ptyand!use_pty: thekillbelow, and a typed Ctrl-CBefore the second-SIGTERM fix, 3 of 8 runs failed at a 3 ms gap on Debian 13. singed was killed before it opened the flamegraph. I haven't run it on macOS. To try it there, this should stop after about 10 s and open a flamegraph:
I've left three narrow windows as they are. Fresh Eyes flagged the first two:
Process.wait2reaps sudo, but before the SIGTERM trap is restored, a SIGTERM would signal a PID that may have been reused. Operating systems allocate PIDs sequentially, so reuse within those microseconds is practically impossible, and the once-only guard limits it to a first SIGTERM.chown: a first SIGTERM that arrives during the few milliseconds ofsudo chowninterrupts it, so singed exits with an error and leaves the file owned by root. Either way it opens no flamegraph.&does, a SIGTERM in sudo's first few milliseconds is lost, so singed waits for the command to exit.mainmisbehaves at any time.