mpv: blast: shut it down properly even when sandboxed

it only cost everything. also, blast doesnt reliably clean up its pseudo devices
This commit is contained in:
Colin 2024-03-12 11:51:15 +00:00
parent 01fa9919fd
commit eabd113262
3 changed files with 91 additions and 15 deletions

View File

@ -2,7 +2,10 @@
#!nix-shell -i python3 -p "python3.withPackages (ps: [ ])" -p blast-ugjka
# vim: set filetype=python :
import ctypes
import logging
import os
import signal
import socket
import subprocess
@ -16,6 +19,48 @@ DEVICE_MAP = {
"[LG] webOS TV OLED55C9PUA": [ "-usewav" ],
}
def set_pdeathsig(sig=signal.SIGTERM):
"""
helper function to ensure once parent process exits, its children processes will automatically die.
see: <https://stackoverflow.com/a/43152455>
see: <https://www.man7.org/linux/man-pages/man2/prctl.2.html>
"""
libc = ctypes.CDLL("libc.so.6")
return libc.prctl(1, sig)
MY_PID = None
def reap_children(*args, **kwargs):
global MY_PID
logger.info("killing all children (of pid %d)", MY_PID)
os.killpg(MY_PID, signal.SIGTERM)
def reap_on_exit():
"""
catch when the parent exits, and map that to SIGTERM for this process.
when this process receives SIGTERM, also terminate all descendent processes.
this is done because:
1. mpv invokes this, but (potentially) via the sandbox wrapper.
2. when mpv exits, it `SIGKILL`s that sandbox wrapper.
3. bwrap does not pass SIGKILL or SIGTERM to its child.
4. hence, we neither receive that signal NOR can we pass it on simply by killing our immediate children
(since any bwrap'd children wouldn't pass that signal on...)
really, the proper fix would be on mpv's side:
- mpv should create a new process group when it launches a command, and kill that process group on exit.
or fix this in the sandbox wrapper:
- why *doesn't* bwrap forward the signals?
- there's --die-with-parent, but i can't apply that *system wide* and expect reasonably behavior
<https://github.com/containers/bubblewrap/issues/529>
"""
global MY_PID
MY_PID = os.getpid()
# create a new process group, pgid = gid
os.setpgid(MY_PID, MY_PID)
set_pdeathsig(signal.SIGTERM)
signal.signal(signal.SIGTERM, reap_children)
def get_ranked_ip_addrs():
"""
return the IP addresses most likely to be LAN addresses
@ -37,13 +82,21 @@ class Status(Enum):
Continue = "continue"
Error = "error"
RedoWithFlags = "redo_with_flags"
Launched = "launched"
class BlastDriver:
parsing: ParserState | None = None
last_write: str | None = None
def __init__(self, blast_flags: list[str] = []):
self.ranked_ips = get_ranked_ip_addrs()
self.blast = subprocess.Popen(["blast", "-source", "blast.monitor"] + blast_flags, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
self.blast = subprocess.Popen(
["blast", "-source", "blast.monitor"] + blast_flags,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
# TODO: is this pdeathsig necessary? probably not.
preexec_fn=set_pdeathsig
)
self.blast_flags = list(blast_flags)
self.receiver_names = []
self.ips = []
@ -89,9 +142,9 @@ class BlastDriver:
elif line == "Select the lan IP address for the stream:":
for r in self.ranked_ips:
if r in self.ips:
return Status.Continue, str(self.ips.index(r))
return Status.Launched, str(self.ips.index(r))
# fallback: just guess the best IP
return Status.Continue, "0"
return Status.Launched, "0"
elif self.parsing == ParserState.Receiver:
id_, name = line.split(": ")
assert id_ == str(len(self.receiver_names)), (id_, self.receiver_names)
@ -123,7 +176,7 @@ class BlastDriver:
self.writeline(reply)
return status
def try_blast(*args, **kwargs):
def try_blast(*args, **kwargs) -> BlastDriver | None:
blast = BlastDriver(*args, **kwargs)
status = Status.Continue
while status == Status.Continue:
@ -134,14 +187,28 @@ def try_blast(*args, **kwargs):
blast_flags = DEVICE_MAP[dev]
logger.info("re-exec blast for %s with flags: %r", dev, blast_flags)
blast.blast.terminate()
try_blast(blast_flags=blast_flags)
return try_blast(blast_flags=blast_flags)
elif status == Status.Error:
logger.info("blast error => terminating")
blast.blast.terminate()
else:
# successfully launched
return blast
def main():
logging.basicConfig()
logging.getLogger().setLevel(logging.DEBUG)
try_blast()
reap_on_exit()
blast = try_blast()
if blast is not None:
logger.info("waiting until blast exits")
blast.blast.wait()
reap_children()
if __name__ == "__main__":
main()

View File

@ -39,6 +39,11 @@ in
sandbox.method = "bwrap";
sandbox.whitelistAudio = true;
sandbox.net = "clearnet";
sandbox.extraConfig = [
# else it fails to reap its children (or, maybe, it fails to hook its parent's death signal?)
# might be possible to remove this, but kinda hard to see a clean way.
"--sane-sandbox-keep-namespace" "pid"
];
suggestedPrograms = [ "blast-ugjka" ];
};

View File

@ -1,18 +1,22 @@
function invoke_blast()
function subprocess(in_terminal, args)
if in_terminal then
args = { "xdg-terminal-exec", table.unpack(args) }
end
mp.command_native({
name = "subprocess",
-- TODO: mpv shutdown hangs if not run w/o xdg-terminal-exec??
args = { "xdg-terminal-exec", "blast-to-default" },
args = args,
detach = false,
capture_stdout = false,
capture_stderr = false,
-- capture_size=0,
passthrough_stdin = false,
playback_only = false,
})
end
function invoke_go2tv(in_terminal, args)
local full_args = { "go2tv", table.unpack(args) }
if in_terminal then
full_args = { "xdg-terminal-exec", table.unpack(full_args) }
end
mp.commandv("set", "pause", "yes")
mp.command_native({ name = "subprocess", args = full_args })
subprocess(in_terminal, { "go2tv", table.unpack(args) })
end
function invoke_go2tv_on_open_file(mode)
@ -20,7 +24,7 @@ function invoke_go2tv_on_open_file(mode)
return invoke_go2tv(true, { mode, path })
end
mp.add_key_binding(nil, "blast", invoke_blast)
mp.add_key_binding(nil, "blast", function() subprocess(false, { "blast-to-default" }) end)
mp.add_key_binding(nil, 'go2tv-gui', function() invoke_go2tv(false, {}) end)
mp.add_key_binding(nil, 'go2tv-video', function() invoke_go2tv_on_open_file("-v") end)
mp.add_key_binding(nil, 'go2tv-stream', function() invoke_go2tv_on_open_file("-s") end)