Thanks for the responses, and sorry for the faulty question (this was using Ctrl-C in the launch window to shutdown, though). Indeed the above example acts the same under Melodic - I was following a red herring tracking down the problem and trying to post a minimal example.
In my real system, I have a Python script that builds a launch file on the fly and gives it to roslaunch. After more experimenting, this seems to be a Python2 vs. 3 difference with subprocess.
If I use this trivial launch file "x.launch":
<launch>
<node name="rviz" pkg="rviz" type="rviz" respawn="True" output="screen"/>
</launch>
then roslaunch x.launch, it works, and ^C shuts it down.
With a minimal version of my script:
import subprocess
try:
subprocess.call(('roslaunch', "x.launch"))
except KeyboardInterrupt:
print("KEYINT")
running with python2 (still Ubuntu20 and Noetic), and stop with ^C:
^CKEYINT
$ [rviz-2] killing on exit
[rosout-1] killing on exit
[master] killing on exit
shutting down processing monitor...
... shutting down processing monitor complete
done
and stuff is properly shut down. But running the same with python3:
^C[rviz-2] killing on exit
KEYINT
it's only killing Rviz, leaving rosmaster and rosout running. It was harder to see that this is what was happening in my full environment that opens various terminals and tabs. If I add "raise" to the exception handler (which in the real version does some cleanup), then it shuts down master and other nodes (with either Py2 or 3).
I suspect this won't be the last surprise Python 3 has in store.