From f7bc2a8f683a14a8f5abe73035920453b8b16892 Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:23:20 +1000 Subject: [PATCH] Android launcher: reap a previous launch's Lepton left by a SIGKILLed launcher The lock isn't inherited by Lepton, so a launcher killed before Lepton made its container left an untracked Lepton that a new Play could overlap. The launcher records its child's process group and, once it holds the lock, ends a recorded group that is still running this app.apk (never an unrelated reused id). Co-Authored-By: Claude Opus 5.5 (1M context) --- frame/android/lepton-app.sh | 16 ++++++++++++++++ tests/test_frame_android_library.py | 24 +++++++++++++++++++++++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/frame/android/lepton-app.sh b/frame/android/lepton-app.sh index 594a6a9..8f393e2 100644 --- a/frame/android/lepton-app.sh +++ b/frame/android/lepton-app.sh @@ -30,6 +30,20 @@ fi exec 9>"$DIR/launch.lock" flock -n 9 || { echo "Android app is already running" >&2; exit 1; } CONTAINER="lepton-steamlaunch-$SteamAppId" +# Lepton doesn't hold the lock, so a launcher SIGKILLed before Lepton made its +# container leaves a Lepton that nothing tracks: end that process group first. +# Only a group still running this app.apk, never an unrelated reused id. +PGID_FILE="$DIR/launch.pgid" +if [[ -f "$PGID_FILE" ]]; then + old="$(cat "$PGID_FILE")" + if [[ "$old" =~ ^[0-9]+$ ]] && ps -A -o pgid=,args= | awk -v g="$old" '$1 == g' | grep -qF -- "$DIR/app.apk"; then + echo "Stopping the previous launch (process group $old)" >&2 + kill -TERM -- "-$old" 2>/dev/null || true + for _ in 1 2 3 4 5 6 7 8 9 10; do kill -0 -- "-$old" 2>/dev/null || break; sleep 0.5; done + kill -KILL -- "-$old" 2>/dev/null || true + fi + rm -f "$PGID_FILE" +fi # Holding the lock means no launcher owns a running container: it was orphaned # (this script SIGKILLed), so stop it rather than refuse every later Play. if [[ "$(podman inspect --format '{{.State.Running}}' "$CONTAINER" 2>/dev/null || true)" == true ]]; then @@ -60,6 +74,7 @@ cleanup() { kill -KILL "$child" 2>/dev/null || true wait "$child" 2>/dev/null || true fi + rm -f "$PGID_FILE" } trap cleanup EXIT trap 'exit 143' TERM @@ -68,6 +83,7 @@ trap 'exit 129' HUP # 9>&-: the lock is this launcher's alone; Lepton's tree mustn't keep it held. setsid --wait "$LEPTON" waitforexitandrun -- "$DIR/app.apk" 9>&- & child=$! +echo "$child" > "$PGID_FILE" rc=0 wait "$child" || rc=$? child="" diff --git a/tests/test_frame_android_library.py b/tests/test_frame_android_library.py index fdee8cb..66ad857 100644 --- a/tests/test_frame_android_library.py +++ b/tests/test_frame_android_library.py @@ -24,7 +24,7 @@ spec.loader.exec_module(shortcuts) @unittest.skipIf(os.name == 'nt', 'POSIX launcher') class LauncherTests(unittest.TestCase): - def exercise(self, terminate, sig=signal.SIGTERM, blocked=None, orphan=False): + def exercise(self, terminate, sig=signal.SIGTERM, blocked=None, orphan=False, stale=None): with tempfile.TemporaryDirectory() as tmp: d = Path(tmp) app = d / 'Applications/Android/org.test.app' @@ -60,6 +60,13 @@ class LauncherTests(unittest.TestCase): saved = d / '.local/share/Steam/steamapps/compatdata/2800000001/internal/save' saved.parent.mkdir(parents=True) saved.write_text('saved game') + previous = None + if stale: + # A Lepton left by a SIGKILLed launcher (its own session), or an unrelated reused id. + args = [sys.executable, '-c', 'import time; time.sleep(30)'] + previous = subprocess.Popen(args + ([str(app / 'app.apk')] if stale == 'lepton' else ['other']), + start_new_session=True) + (app / 'launch.pgid').write_text(str(previous.pid)) proc = subprocess.Popen(['bash', str(app / 'launch.sh')], env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE) try: if blocked: @@ -73,6 +80,10 @@ class LauncherTests(unittest.TestCase): while not (d / 'started').exists() and proc.poll() is None and time.monotonic() < deadline: time.sleep(.02) self.assertTrue((d / 'started').exists(), 'launcher did not start Lepton') + if previous: + self.assertEqual(previous.poll() is not None, stale == 'lepton') + self.assertEqual((app / 'launch.pgid').read_text().strip(), + (d / 'started').read_text().strip()) # the new group is recorded self.assertFalse((d / 'inherited-lock').exists(), 'Lepton inherited the launch lock') if terminate: self.assertIsNone(proc.poll(), 'Steam-tracked wrapper exited during the session') @@ -84,7 +95,12 @@ class LauncherTests(unittest.TestCase): self.assertEqual(proc.returncode, 128 + sig if terminate else 23) self.assertEqual(saved.read_text(), 'saved game') self.assertTrue((app / 'app.apk').exists()) + self.assertFalse((app / 'launch.pgid').exists()) finally: + if previous and previous.poll() is None: + previous.kill() + if previous: + previous.wait() if proc.poll() is None: proc.kill() proc.communicate() @@ -105,6 +121,12 @@ class LauncherTests(unittest.TestCase): def test_duplicate_launch_leaves_existing_session_alone(self): self.exercise(False, blocked='LOCKED') + def test_previous_launch_left_by_sigkill_is_reaped_first(self): + self.exercise(True, stale='lepton') + + def test_reused_process_group_id_is_left_alone(self): + self.exercise(True, stale='other') + def test_orphaned_container_is_stopped_and_play_proceeds(self): # Container running but the lock free: its launcher was SIGKILLed. self.exercise(False, orphan=True)