If you find yourself in a hole, stop digging, Will Rogers.

Launching graphical applications from a menu like Rofi seems trivial —just add an ampersand & and move on, right? In reality, process management in a tiling window manager session is fraught with edge cases. A naive & keeps the child process in the same process group as the launcher, often causing X11 to hang onto the keyboard grab long after the menu disappears, or causing the app to terminate unexpectedly if the script exits. Conversely, tools like nohup solve the persistence problem but often strip essential environment variables like DBUS_SESSION_BUS_ADDRESS, breaking the application’s integration with the desktop session.
In this article, we introduce myrofi_launch.py, a robust Python utility designed to replace fragile Bash backgrounding helpers. By leveraging subprocess.Popen with start_new_session=True, we create a clean process group for every application, allowing the kernel to immediately re-parent the app to systemd (PID 1). This ensures that applications like Google Chrome or VS Code launch completely independently, preserving their full environment and releasing the input focus instantly.
#!/usr/bin/env python3
"""
myrofi_launch.py — Drop-in replacement for bash's _launch() helper
───────────────────────────────────────────────────────────────────
Launches any app fully detached, exactly like `( cmd & )` in bash.
USAGE FROM rofi-launcher.sh:
Replace every _launch [args] call with:
python3 "$HOME/dotfiles/scripts/myrofi_launch.py" [args]
Examples — direct replacements:
# bash # python
_launch pcmanfm → python3 .../myrofi_launch.py pcmanfm
_launch keepassxc → python3 .../myrofi_launch.py keepassxc
_launch google-chrome-stable → python3 .../myrofi_launch.py google-chrome-stable
_launch code → python3 .../myrofi_launch.py code
_launch rofi -show drun → python3 .../myrofi_launch.py rofi -show drun
Or import and call from another Python script:
from myrofi_launch import launch
launch(["pcmanfm"])
launch(["google-chrome-stable", "--app=https://mail.google.com"])
WHY NOT just use & or nohup in bash:
- Plain `cmd &` keeps the child in the same process group as rofi.
When rofi exits, X11 may not release its keyboard grab cleanly
because the child is still in the same session.
- nohup strips environment variables that GUI apps need to connect to the session bus.
- subprocess.Popen with start_new_session=True forks cleanly AND
preserves the full environment. Best of both worlds.
HOW DETACHMENT WORKS (same as ( cmd & ) in bash):
1. Popen() forks the child and returns immediately — no waiting.
2. close_fds=True → child does not inherit our file descriptors.
3. start_new_session=True → child gets its own process group (setsid).
When this script exits, the kernel re-parents the child to PID 1
(systemd). The child is now fully independent.
4. stdout/stderr → /dev/null → child output does not pollute the journal.
5. The parent (this script) exits with code 0 immediately after forking.
Rofi sees a clean exit and releases the X11 keyboard grab.
"""
import subprocess
import sys
import os
def launch(command: list[str]) -> None:
"""
Fork a process and return immediately, leaving it fully detached.
Args:
command: Command as a list of strings. Never use shell=True —
a list avoids all quoting and injection issues.
Example: ["google-chrome-stable", "--app=https://mail.google.com"]
Raises:
FileNotFoundError: if the binary is not found on PATH.
The error is caught in main() and printed to stderr.
"""
subprocess.Popen(
command,
stdout=subprocess.DEVNULL, # suppress child stdout → not in journal
stderr=subprocess.DEVNULL, # suppress child stderr → not in journal
close_fds=True, # don't leak our file descriptors to child
start_new_session=True, # new session: child survives our exit,
# kernel re-parents it to PID 1 (systemd)
)
# Popen() returns immediately — we do NOT call .wait() or .communicate().
# The child is now running independently in the background.
def main() -> None:
"""
Entry point when called from the command line / rofi-launcher.sh.
sys.argv[1:] is everything after the script name, which becomes
the command list passed to launch().
Example:
python3 myrofi_launch.py google-chrome-stable --app=https://mail.google.com
→ launch(["google-chrome-stable", "--app=https://mail.google.com"])
"""
if len(sys.argv) < 2:
print(
"Usage: myrofi_launch.py [args...] \n"
"Example: myrofi_launch.py google-chrome-stable\n"
"Example: myrofi_launch.py rofi -show drun",
file=sys.stderr,
)
sys.exit(1)
command = sys.argv[1:] # ["google-chrome-stable"] or ["rofi", "-show", "drun"]
try:
launch(command)
except FileNotFoundError:
print(
f"myrofi_launch: command not found: {command[0]!r}\n"
f"Check it is installed and on $PATH with: which {command[0]}",
file=sys.stderr,
)
sys.exit(127) # 127 = standard shell "command not found" exit code
if __name__ == "__main__":
main()