diff --git a/frame/android/lepton-app.sh b/frame/android/lepton-app.sh index 119850b..594a6a9 100644 --- a/frame/android/lepton-app.sh +++ b/frame/android/lepton-app.sh @@ -30,9 +30,11 @@ fi exec 9>"$DIR/launch.lock" flock -n 9 || { echo "Android app is already running" >&2; exit 1; } CONTAINER="lepton-steamlaunch-$SteamAppId" +# 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 - echo "Android container is already running" >&2 - exit 1 + echo "Stopping orphaned $CONTAINER" >&2 + podman stop -t 5 "$CONTAINER" >/dev/null 2>&1 || true fi export STEAM_COMPAT_INSTALL_PATH="$DIR" # Must be under ~/.local/share/Steam: only that tree is mounted in the container. @@ -63,7 +65,8 @@ trap cleanup EXIT trap 'exit 143' TERM trap 'exit 130' INT trap 'exit 129' HUP -setsid --wait "$LEPTON" waitforexitandrun -- "$DIR/app.apk" & +# 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=$! rc=0 wait "$child" || rc=$? diff --git a/tests/test_frame_android_library.py b/tests/test_frame_android_library.py index dea31a6..c34046f 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): + def exercise(self, terminate, sig=signal.SIGTERM, blocked=None, orphan=False): with tempfile.TemporaryDirectory() as tmp: d = Path(tmp) app = d / 'Applications/Android/org.test.app' @@ -44,6 +44,7 @@ class LauncherTests(unittest.TestCase): 'assert os.environ["SteamAppId"] == "2800000001"\n' 'assert os.environ["LEPTON_ENV_SteamAppId"] == "3346865537"\n' 'Path(os.environ["HOME"],"started").write_text(str(os.getpid()))\n' + 'try:\n os.fstat(9); Path(os.environ["HOME"],"inherited-lock").touch()\nexcept OSError: pass\n' + ('time.sleep(30)\n' if terminate else 'raise SystemExit(23)\n')) script(bin_dir / 'setsid', 'import os,sys\nos.setsid()\nos.execv(sys.argv[2],sys.argv[2:])\n') script(bin_dir / 'flock', 'import os\nraise SystemExit(1 if os.environ.get("TEST_LOCKED") else 0)\n') # lock semantics belong to Linux; no flock on macOS @@ -54,6 +55,8 @@ class LauncherTests(unittest.TestCase): env = {**os.environ, 'HOME': str(d), 'PATH': str(bin_dir) + os.pathsep + os.environ['PATH']} if blocked: env['TEST_' + blocked] = '1' + if orphan: + env['TEST_RUNNING'] = '1' saved = d / '.local/share/Steam/steamapps/compatdata/2800000001/internal/save' saved.parent.mkdir(parents=True) saved.write_text('saved game') @@ -70,12 +73,14 @@ 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') + self.assertFalse((d / 'inherited-lock').exists(), 'Lepton inherited the launch lock') if terminate: self.assertIsNone(proc.poll(), 'Steam-tracked wrapper exited during the session') proc.send_signal(sig) _, err = proc.communicate(timeout=5) calls = (d / 'podman-calls').read_text() if (d / 'podman-calls').exists() else '' self.assertIn('stop -t 5 lepton-steamlaunch-2800000001', calls, err.decode()) + self.assertEqual(calls.count('stop -t 5'), 2 if orphan else 1) self.assertEqual(proc.returncode, 128 + sig if terminate else 23) self.assertEqual(saved.read_text(), 'saved game') self.assertTrue((app / 'app.apk').exists()) @@ -98,9 +103,11 @@ class LauncherTests(unittest.TestCase): self.exercise(True, sig) def test_duplicate_launch_leaves_existing_session_alone(self): - for blocked in ('LOCKED', 'RUNNING'): - with self.subTest(blocked=blocked): - self.exercise(False, blocked=blocked) + self.exercise(False, blocked='LOCKED') + + 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) def test_normal_exit_cleans_container_and_keeps_exit_code(self): self.exercise(False)