mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 06:00:33 +02:00
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) <noreply@anthropic.com>
This commit is contained in:
1 parent
6c1ece1b62
commit
f7bc2a8f68
2 files changed
+39
-1
No files matched your search
@@ -30,6 +30,20 @@ fi
|
|||||||
exec 9>"$DIR/launch.lock"
|
exec 9>"$DIR/launch.lock"
|
||||||
flock -n 9 || { echo "Android app is already running" >&2; exit 1; }
|
flock -n 9 || { echo "Android app is already running" >&2; exit 1; }
|
||||||
CONTAINER="lepton-steamlaunch-$SteamAppId"
|
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
|
# 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.
|
# (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
|
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
|
kill -KILL "$child" 2>/dev/null || true
|
||||||
wait "$child" 2>/dev/null || true
|
wait "$child" 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
|
rm -f "$PGID_FILE"
|
||||||
}
|
}
|
||||||
trap cleanup EXIT
|
trap cleanup EXIT
|
||||||
trap 'exit 143' TERM
|
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.
|
# 9>&-: the lock is this launcher's alone; Lepton's tree mustn't keep it held.
|
||||||
setsid --wait "$LEPTON" waitforexitandrun -- "$DIR/app.apk" 9>&- &
|
setsid --wait "$LEPTON" waitforexitandrun -- "$DIR/app.apk" 9>&- &
|
||||||
child=$!
|
child=$!
|
||||||
|
echo "$child" > "$PGID_FILE"
|
||||||
rc=0
|
rc=0
|
||||||
wait "$child" || rc=$?
|
wait "$child" || rc=$?
|
||||||
child=""
|
child=""
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ spec.loader.exec_module(shortcuts)
|
|||||||
|
|
||||||
@unittest.skipIf(os.name == 'nt', 'POSIX launcher')
|
@unittest.skipIf(os.name == 'nt', 'POSIX launcher')
|
||||||
class LauncherTests(unittest.TestCase):
|
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:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
d = Path(tmp)
|
d = Path(tmp)
|
||||||
app = d / 'Applications/Android/org.test.app'
|
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 = d / '.local/share/Steam/steamapps/compatdata/2800000001/internal/save'
|
||||||
saved.parent.mkdir(parents=True)
|
saved.parent.mkdir(parents=True)
|
||||||
saved.write_text('saved game')
|
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)
|
proc = subprocess.Popen(['bash', str(app / 'launch.sh')], env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||||
try:
|
try:
|
||||||
if blocked:
|
if blocked:
|
||||||
@@ -73,6 +80,10 @@ class LauncherTests(unittest.TestCase):
|
|||||||
while not (d / 'started').exists() and proc.poll() is None and time.monotonic() < deadline:
|
while not (d / 'started').exists() and proc.poll() is None and time.monotonic() < deadline:
|
||||||
time.sleep(.02)
|
time.sleep(.02)
|
||||||
self.assertTrue((d / 'started').exists(), 'launcher did not start Lepton')
|
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')
|
self.assertFalse((d / 'inherited-lock').exists(), 'Lepton inherited the launch lock')
|
||||||
if terminate:
|
if terminate:
|
||||||
self.assertIsNone(proc.poll(), 'Steam-tracked wrapper exited during the session')
|
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(proc.returncode, 128 + sig if terminate else 23)
|
||||||
self.assertEqual(saved.read_text(), 'saved game')
|
self.assertEqual(saved.read_text(), 'saved game')
|
||||||
self.assertTrue((app / 'app.apk').exists())
|
self.assertTrue((app / 'app.apk').exists())
|
||||||
|
self.assertFalse((app / 'launch.pgid').exists())
|
||||||
finally:
|
finally:
|
||||||
|
if previous and previous.poll() is None:
|
||||||
|
previous.kill()
|
||||||
|
if previous:
|
||||||
|
previous.wait()
|
||||||
if proc.poll() is None:
|
if proc.poll() is None:
|
||||||
proc.kill()
|
proc.kill()
|
||||||
proc.communicate()
|
proc.communicate()
|
||||||
@@ -105,6 +121,12 @@ class LauncherTests(unittest.TestCase):
|
|||||||
def test_duplicate_launch_leaves_existing_session_alone(self):
|
def test_duplicate_launch_leaves_existing_session_alone(self):
|
||||||
self.exercise(False, blocked='LOCKED')
|
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):
|
def test_orphaned_container_is_stopped_and_play_proceeds(self):
|
||||||
# Container running but the lock free: its launcher was SIGKILLed.
|
# Container running but the lock free: its launcher was SIGKILLed.
|
||||||
self.exercise(False, orphan=True)
|
self.exercise(False, orphan=True)
|
||||||
|
|||||||
Reference in new issue
Block a user