diff --git a/native/gamepad.cpp b/native/gamepad.cpp index 32c489f..20b454f 100644 --- a/native/gamepad.cpp +++ b/native/gamepad.cpp @@ -1,7 +1,6 @@ // Session-owned OpenVR -> Android gamepad. No CEF dependency or timed test limit. #include "openvr.h" #include -#include #include #include #include @@ -23,7 +22,10 @@ static bool event(int fd,int type,int code,int value){input_event e{};e.type=typ static bool send(int fd,const int* state){bool ok=true;for(int k=0;k<10;k++)ok=event(fd,EV_KEY,keys[k],state[k])&&ok;for(int k=0;k<8;k++)ok=event(fd,EV_ABS,axes[k],state[10+k])&&ok;return event(fd,EV_SYN,SYN_REPORT,0)&&ok;} int main(int argc,char**argv){ if(argc!=2||geteuid()==0){fprintf(stderr,"Run gamepad bridge as the Steam session user with an action manifest.\n");return 1;} - auto parent=getppid();prctl(PR_SET_PDEATHSIG,SIGTERM);if(getppid()!=parent)return 1; + // A session request runs on a short-lived worker thread. PR_SET_PDEATHSIG + // follows that thread's lifetime, so it destroys the pad as soon as prepare + // returns. The session-owned stdin pipe and parent process own this helper. + const auto parent=getppid(); signal(SIGTERM,stop);signal(SIGINT,stop);signal(SIGPIPE,SIG_IGN); vr::EVRInitError error;vr::VR_Init(&error,vr::VRApplication_Overlay);if(error){fprintf(stderr,"SteamVR gamepad input unavailable: %d\n",error);return 1;} auto*input=vr::VRInput();vr::VRActiveActionSet_t set{}; @@ -47,7 +49,7 @@ int main(int argc,char**argv){ printf("%s\n",node.c_str());fflush(stdout); // Parent owns routing. Start neutral; EOF, disconnect or disabled actions release input. bool enabled=false;std::string commands;int previous[18]{}; - while(!stopped){pollfd p{STDIN_FILENO,POLLIN,0};if(poll(&p,1,0)>0){char buffer[128];auto count=read(0,buffer,sizeof buffer);if(count<=0)break;commands.append(buffer,count);size_t at;while((at=commands.find('\n'))!=std::string::npos){auto command=commands.substr(0,at);commands.erase(0,at+1);enabled=command=="enable";}if(commands.size()>128)break;} + while(!stopped&&getppid()==parent){pollfd p{STDIN_FILENO,POLLIN,0};if(poll(&p,1,0)>0){char buffer[128];auto count=read(0,buffer,sizeof buffer);if(count<=0)break;commands.append(buffer,count);size_t at;while((at=commands.find('\n'))!=std::string::npos){auto command=commands.substr(0,at);commands.erase(0,at+1);enabled=command=="enable";}if(commands.size()>128)break;} int state[18]{}; // If Android releases its exclusive claim (e.g. InputReader restart), // pause until it claims the node again. Do not feed other host consumers. diff --git a/src/apk.rs b/src/apk.rs index 92e93b9..2ddf918 100644 --- a/src/apk.rs +++ b/src/apk.rs @@ -4421,7 +4421,8 @@ setup_props ) .unwrap(); assert!(native::prepare(&f.home, id, 0x92345679).is_err()); - let (c, a, response) = native::prepare(&f.home, id, 0x92345678).unwrap(); + let (c, a, response, ownership) = native::prepare(&f.home, id, 0x92345678).unwrap(); + assert!(native::prepare(&f.home, id, 0x92345678).is_err()); assert_eq!(c.id, "test"); assert_eq!(c.name, "steamlaunch-2452903544"); assert!(!c.steam); @@ -4439,6 +4440,8 @@ setup_props fs::read(installed_dir.join("lib/arm64/game.so")).unwrap(), b"existing extracted library" ); + drop(ownership); + assert!(native::prepare(&f.home, id, 0x92345678).is_ok()); } #[test] fn unreadable_state_is_not_uninstallation_or_a_launch_target() { diff --git a/src/apk/native.rs b/src/apk/native.rs index 5913d8d..9823cef 100644 --- a/src/apk/native.rs +++ b/src/apk/native.rs @@ -49,7 +49,11 @@ pub(super) fn runtime_name(r: &Record) -> Option { .map(|id| format!("steamlaunch-{id}")) } -pub(super) fn prepare(home: &Path, id: &str, app_id: u32) -> Result<(Container, App, Value)> { +pub(super) fn prepare( + home: &Path, + id: &str, + app_id: u32, +) -> Result<(Container, App, Value, fs::File)> { let _guard = MUTATION .try_lock() .map_err(|_| anyhow::anyhow!("Another APK operation is running"))?; @@ -71,6 +75,23 @@ pub(super) fn prepare(home: &Path, id: &str, app_id: u32) -> Result<(Container, && r.steam_app_id == Some(app_id), "Steam APK binding changed; refresh the entry and retry" ); + // Steam's launcher closes inherited descriptors. Keep this lock in the + // session service, which owns it until the launch and container have ended. + fs::create_dir_all(root(home).join("steam"))?; + let ownership = fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(false) + .mode(0o600) + .open( + root(home) + .join("steam") + .join(format!("context-{}.lock", hash(&r.context))), + )?; + ensure!( + unsafe { libc::flock(ownership.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0, + "Another application is starting or running in this container" + ); let (a, mut c) = app(home, &db, id)?; ensure!( a.installed && a.state_known && a.pending.is_none(), @@ -163,7 +184,7 @@ pub(super) fn prepare(home: &Path, id: &str, app_id: u32) -> Result<(Container, } db.records.get_mut(id).unwrap().steam_app_id = Some(app_id); save(home, &db)?; - Ok((c, a, json!({"script":path,"env":env}))) + Ok((c, a, json!({"script":path,"env":env}), ownership)) } pub(super) fn run(app: &str, token: &str, command: &[String]) -> Result<()> { @@ -224,40 +245,6 @@ pub(super) fn run(app: &str, token: &str, command: &[String]) -> Result<()> { fs::canonicalize(&command[entry + 3])? == fs::canonicalize(&apk)?, "Unexpected APK launch target" ); - fs::create_dir_all(root(&home).join("steam"))?; - let context_lock = fs::OpenOptions::new() - .write(true) - .create(true) - .truncate(false) - .mode(0o600) - .open( - root(&home) - .join("steam") - .join(format!("context-{}.lock", hash(&r.context))), - )?; - ensure!( - unsafe { libc::flock(context_lock.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0, - "Another application is starting or running in this container" - ); - ensure!( - unsafe { libc::fcntl(context_lock.as_raw_fd(), libc::F_SETFD, 0) } == 0, - "Cannot retain native storage ownership" - ); - let lock = fs::OpenOptions::new() - .write(true) - .create(true) - .truncate(false) - .mode(0o600) - .open(steam_shortcuts::wrapper(&home, app).with_extension("owner.lock"))?; - ensure!( - unsafe { libc::flock(lock.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0, - "This APK already has an active Steam launch" - ); - // Keep the ownership lock across exec and Lepton's process-group launcher. - ensure!( - unsafe { libc::fcntl(lock.as_raw_fd(), libc::F_SETFD, 0) } == 0, - "Cannot retain native launch ownership" - ); let response = steam_bridge::native_call( app, token, diff --git a/src/apk/steam_bridge.rs b/src/apk/steam_bridge.rs index ccb58ab..5394291 100644 --- a/src/apk/steam_bridge.rs +++ b/src/apk/steam_bridge.rs @@ -274,14 +274,18 @@ fn handle(home: &Path, pid: u32, v: Value) -> Result { .and_then(|v| u32::try_from(v).ok()) .filter(|v| *v >= 0x80000000) .context("Launch this APK entry through Steam")?; - let (c, a, response) = super::native::prepare(home, id, app_id)?; + let (c, a, response, ownership) = super::native::prepare(home, id, app_id)?; let owner_signature = signature(pid).context("Steam launch exited")?; let home = home.to_owned(); let id = id.to_owned(); let token = token.to_owned(); + let gamepad_event = response["env"]["FRAMELY_GAMEPAD_EVENT"] + .as_str() + .map(PathBuf::from); std::thread::spawn(move || { let result = (|| -> Result { let deadline = Instant::now() + Duration::from_secs(120); + let creation_deadline = Instant::now() + Duration::from_secs(45); loop { ensure!( signature(pid).as_deref() == Some(&owner_signature), @@ -291,7 +295,14 @@ fn handle(home: &Path, pid: u32, v: Value) -> Result { Instant::now() < deadline, "Native Lepton startup timed out; inspect the APK log" ); - if let Ok(Some(instance)) = current_instance(&c) { + let instance = current_instance(&c)?; + if instance.is_none() { + if let Some(event) = &gamepad_event { + ensure!(event.exists(), "Virtual gamepad disappeared before Lepton created its container; retry the launch"); + } + ensure!(Instant::now() < creation_deadline, "Lepton did not create its container; inspect the native Lepton startup log"); + } + if let Some(instance) = instance { if let Ok(s) = lifecycle::sample(&c) { if s.package == a.metadata.package && s.alive.contains(&a.metadata.package) @@ -316,12 +327,32 @@ fn handle(home: &Path, pid: u32, v: Value) -> Result { std::thread::sleep(Duration::from_millis(500)); } })(); + // A failed Podman run can leave Lepton waiting for a create + // event forever. End only this verified, still-uncreated launch; + // never interrupt a container that has already booted. + if result.is_err() && matches!(current_instance(&c), Ok(None)) { + if signature(pid).as_deref() == Some(&owner_signature) { + unsafe { + libc::kill(pid as i32, libc::SIGTERM); + } + } + crate::gamepad::stop_context(&c.name); + } if let Some(p) = PENDING.lock().unwrap().remove(&id) { if result.is_ok() { (p.progress)(json!({"phase":"started"})); } let _ = p.result.send(result.map_err(|e| format!("{e:#}"))); } + // Preserve storage ownership after reporting startup success. + // Steam drops inherited fds, so the service retains this lock + // through exit and the container's eventual shutdown. + while signature(pid).as_deref() == Some(&owner_signature) + || !matches!(current_instance(&c), Ok(None)) + { + std::thread::sleep(Duration::from_millis(500)); + } + drop(ownership); }); Ok(response) } diff --git a/tests/gamepad_lifetime.py b/tests/gamepad_lifetime.py new file mode 100644 index 0000000..7275d01 --- /dev/null +++ b/tests/gamepad_lifetime.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +"""Frame integration check: worker exit retains the pad, pipe EOF removes it. + +Run as the Steam user with SteamVR awake. The helper remains disabled throughout; +this test creates its own neutral virtual device and never reads physical input. +Usage: python3 tests/gamepad_lifetime.py HELPER ACTION_MANIFEST +""" +import pathlib +import select +import subprocess +import sys +import threading +import time + + +def check(helper, manifest): + state = {} + + def worker(): + try: + child = subprocess.Popen( + [helper, manifest], stdin=subprocess.PIPE, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, + ) + state['child'] = child + if not select.select([child.stdout], [], [], 8)[0]: + raise RuntimeError('Virtual gamepad readiness timed out') + state['node'] = pathlib.Path(child.stdout.readline().decode().strip()) + except Exception as error: + state['error'] = error + + thread = threading.Thread(target=worker) + thread.start() + thread.join(10) + child = state.get('child') + try: + assert not thread.is_alive(), 'Preparation worker did not finish' + if 'error' in state: + raise state['error'] + node = state['node'] + assert node.parent == pathlib.Path('/dev/input') and node.name.startswith('event') + time.sleep(2) + assert child.poll() is None, 'Worker exit destroyed the virtual gamepad' + assert node.is_char_device(), 'Virtual gamepad node disappeared' + child.stdin.close() + assert child.wait(timeout=5) == 0, 'Control pipe EOF did not cleanly stop the helper' + assert not node.exists(), 'Virtual gamepad survived control pipe EOF' + print('PASS: worker exit preserves the gamepad; session pipe close removes it') + finally: + if child is not None and child.poll() is None: + child.terminate() + try: + child.wait(timeout=5) + except subprocess.TimeoutExpired: + child.kill() + child.wait() + + +if __name__ == '__main__': + if len(sys.argv) != 3: + raise SystemExit(__doc__) + check(*sys.argv[1:])