Fix game preparation launched from Foundation process groups

This commit is contained in:
Erick Sanchez committed 2026-10-26 18:55:44 -06:00
1 parent 8a6838d6b8
commit 4bc1fba541
6 files changed
+60 -10

No files matched your search

+4 -1
View File
@@ -4,5 +4,8 @@ import os
import sys
if __name__ == '__main__':
os.setsid()
# Foundation Process already starts a separate process group on macOS.
# A group leader cannot call setsid(); its existing group is safe to cancel.
if os.getpgrp() != os.getpid():
os.setsid()
os.execv('/bin/bash', ['bash', *sys.argv[1:]])
+2 -2
View File
@@ -1,5 +1,5 @@
{
"version": "1.4.1",
"build": 6,
"version": "1.4.2",
"build": 7,
"minimumMacOSMajor": 26
}
+1
View File
@@ -5,6 +5,7 @@ One line per symptom in the [subsystem references](../reference/): what you see
## Packing
- `decompress NSZ` stops immediately with `nsz: error: unrecognized arguments: --keys …`. → [NSZ decompression rejects `--keys`](../reference/packing.md#nsz-decompression-rejects---keys)
- clicking Prepare game stops before any build tools start, with `PermissionError: [Errno 1] Operation not permitted` at `build_session.py` `os.setsid()`. → [Prepare game fails at `os.setsid()`](../reference/packing.md#prepare-game-fails-at-ossetsid)
## Rewriter
+5
View File
@@ -292,3 +292,8 @@ tools and system build dependencies were already installed; initial installation
of those system components, browser quarantine prompts, a fresh macOS user
account and game compilation/gameplay were not exercised by this check. No
game packages, keys or saves were used or modified.
The clean-install check above did not start game preparation. A later user report
exposed a Foundation worker-launch failure, fixed and reproduced in 1.4.2; see
[Prepare game fails at os.setsid](reference/packing.md#prepare-game-fails-at-ossetsid).
The regression suite now exercises the actual Foundation launch mechanism.
+9
View File
@@ -8,3 +8,12 @@
- **Cause:** `nsz` 4.6.1 has no `--keys` argument. It loads `keys.txt` beside `sys.argv[0]`, or falls back to `~/.switch/prod.keys`, while importing its modules, before parsing arguments ([4.6.1 key loader](https://github.com/nicoboss/nsz/blob/4.6.1/nsz/nut/Keys.py)). Newer upstream code supports `--keys`; staging `keys.txt` also supports 4.6.1.
- **Fix:** `scripts/build_ipa_from_nsp.py` `decompress_nsz` links the file selected by `-k` as `keys.txt` in the conversion's temporary folder and runs `nsz -D` there, with `sys.argv[0]` set to `nsz` before import. Input and output paths are absolute. The temporary folder and link are removed on success or failure; the user's default keys are untouched.
- **Found on:** [Super Mario Odyssey](../../dm/games/0100000000010000/README.md), converting its v0 NSZ. Applies to NSZ and XCZ inputs for every game.
## Desktop build worker
### Prepare game fails at `os.setsid()`
- **Symptom:** clicking Prepare game stops before any build tools start, with `PermissionError: [Errno 1] Operation not permitted` at `build_session.py` `os.setsid()`.
- **Cause:** Foundation `Process` already launches the worker as its own process-group leader on macOS. POSIX rejects `setsid()` for a group leader. A normal Python subprocess launch did not expose this difference, so the original regression test missed the GUI path.
- **Fix:** reuse the existing group when `os.getpgrp() == os.getpid()`; call `setsid()` only for a worker that still shares its parent's group. Cancellation still signals the worker's separate group. Do not suppress unrelated permission failures. Added regression coverage for Foundation `Process`, an existing group leader, an existing session leader and a normal subprocess launch, including cancellation.
- **Found on:** a user's clean desktop installation, before game detection; reproduced locally with a Swift Foundation launcher. Applies to every Mac/iPhone game preparation, independent of the game, keys or saves. Released in 1.4.2/build 7.
+39 -7
View File
@@ -13,19 +13,51 @@ ROOT=Path(__file__).resolve().parents[2]
@unittest.skipUnless(sys.platform=='darwin','macOS desktop worker')
class DesktopSession(unittest.TestCase):
def test_worker_has_separate_group_and_cancels(self):
self.check_worker()
def test_worker_already_group_leader(self):
self.check_worker(preexec_fn=os.setpgrp)
def test_worker_already_session_leader(self):
self.check_worker(start_new_session=True)
def test_foundation_process_launch_and_cancellation(self):
with tempfile.TemporaryDirectory() as temporary:
root=Path(temporary);swift=root/'launch.swift';executable=root/'launch'
swift.write_text('''import Foundation
let task=Process()
task.executableURL=URL(fileURLWithPath:CommandLine.arguments[1])
task.arguments=Array(CommandLine.arguments[2...4])
try task.run()
try String(task.processIdentifier).write(toFile:CommandLine.arguments[5],atomically:true,encoding:.utf8)
task.waitUntilExit()
exit(task.terminationStatus)
''')
subprocess.run(['xcrun','swiftc',str(swift),'-o',str(executable)],check=True)
self.check_worker(foundation=executable)
def check_worker(self,foundation=None,**options):
with tempfile.TemporaryDirectory() as temporary:
path=Path(temporary);marker=path/'ready'
script=path/'worker.sh'
script.write_text('echo ready > "$1"\nsleep 30\n')
task=subprocess.Popen([sys.executable,str(ROOT/'desktop/NxNativeEasy/build_session.py'),str(script),str(marker)])
arguments=[sys.executable,str(ROOT/'desktop/NxNativeEasy/build_session.py'),str(script),str(marker)]
pidfile=path/'pid'
if foundation:arguments=[str(foundation),*arguments,str(pidfile)]
task=subprocess.Popen(arguments,**options)
worker_pid=None
try:
deadline=time.monotonic()+5
while not marker.exists() and time.monotonic()<deadline:time.sleep(0.02)
while (not marker.exists() or (foundation and not pidfile.exists())) and time.monotonic()<deadline:time.sleep(0.02)
self.assertTrue(marker.exists())
self.assertEqual(os.getpgid(task.pid),task.pid)
self.assertNotEqual(os.getpgrp(),task.pid)
os.killpg(task.pid,signal.SIGTERM)
self.assertEqual(task.wait(timeout=5),-signal.SIGTERM)
worker_pid=int(pidfile.read_text()) if foundation else task.pid
self.assertEqual(os.getpgid(worker_pid),worker_pid)
self.assertNotEqual(os.getpgrp(),worker_pid)
os.killpg(worker_pid,signal.SIGTERM)
self.assertEqual(task.wait(timeout=5),signal.SIGTERM if foundation else -signal.SIGTERM)
finally:
if task.poll() is None:
os.killpg(task.pid,signal.SIGKILL);task.wait()
if worker_pid is not None:
try:os.killpg(worker_pid,signal.SIGKILL)
except ProcessLookupError:pass
task.kill();task.wait()