mirror of
https://github.com/mihawk-99/PS5_RetroArch.git
synced 2026-10-18 23:09:29 -05:00
Game Forwarder: a deleted tile leaves the home screen seconds after RetroArch closes
The owner's report: tiles deleted in the page stayed on the home screen after closing
RetroArch (from the home screen, and with the picker's Quit). The console's records showed
the removals done, but late: ShadowMountPlus refuses an uninstall while a game runs, and
the WebUI's daemon only tried again at its stop, 20 s after the title closed; a title
reopened within those 20 s left them for the next close.
The daemon now tries a tile's pending work (a removal, a new name or picture) as soon as
no program of the title is linked, and every 3 s while ShadowMountPlus still refuses (the
exiting app answers "game active" for a moment); the try at the stop stays for what is
left. ps5_forwarders::finish_pending says whether anything still waits, and runs under the
WebUI's lock (the page may be open meanwhile). README: "a few seconds after RetroArch
closes".
Evidence:
- evidence/game-forwarder-delete-on-close: on the console, a tile installed, deleted
through the WebUI while RetroArch ran ({"state":"pending"}), RetroArch closed:
/user/appmeta/PPSA99170 gone after 3.0 s; the daemon "13:18:48 not removed yet ...
game_active", "13:18:51 forwarder PPSA99170: removed"; ShadowMountPlus "uninstall
requested" at 13:18:51 (before: 20 s and more).
- tests/test_webui_daemon.py test_a_tile_removal_is_done_as_soon_as_the_title_closes:
nothing while a program is linked, the uninstall within 1.5 s of the unlink, before
the stop (2 s in the tests); test_webui_forwarders checks finish_pending's answer.
- gates: format, unit (211 tests), build, integration (211 tests), evidence (139
captures, 0 failed) - PASS.
This commit is contained in:
1 parent
001bbcf9d1
commit
e776467e3d
10 files changed
+120
-21
No files matched your search
@@ -263,7 +263,7 @@ cartridge or disc, screenshot, title screen or fan art, filling the square or fi
|
||||
the system's icon in the bottom right corner if you like. A picture the game has no file for
|
||||
yet is greyed out; **Download Media** can find it. Choose **Create tile**, then close
|
||||
RetroArch: the tile appears on the home screen. A new name or picture, and a tile you delete,
|
||||
reach the home screen the same way, once RetroArch closes.
|
||||
reach the home screen the same way, a few seconds after RetroArch closes.
|
||||
|
||||
A tile is a small title of its own, `PPSA99170` to `PPSA99269` in `/data/homebrew`, which
|
||||
[ShadowMountPlus](https://github.com/drakmor/ShadowMountPlus) 1.7 or later installs. The page
|
||||
|
||||
+16
-4
@@ -14,8 +14,9 @@
|
||||
* request is being answered: the title closed. An update the WebUI is asked to
|
||||
* install makes every linked program close the title; once none is left, this
|
||||
* installs it (ps5_update::install), the way eboot.bin did after RetroArch quit.
|
||||
* A stop for the title's close finishes what the Game Forwarder page's tiles left for
|
||||
* it: removals and new names or icons (src/ps5_forwarders.h).
|
||||
* What the Game Forwarder page's tiles left for the title's close (removals and new names
|
||||
* or icons, src/ps5_forwarders.h) is done once no program of the title is linked: tried at
|
||||
* once and every few seconds while ShadowMountPlus refuses, and at the stop.
|
||||
*
|
||||
* Its own record is <title>/webui-daemon.txt (the last run's is webui-daemon.1.txt).
|
||||
* tests/test_webui_daemon.py builds it for the host, with its folder, ports and idle
|
||||
@@ -242,8 +243,9 @@ int main()
|
||||
ps5_webui_set_frontend("");
|
||||
std::vector<Link> links;
|
||||
bool serving = false, stale = false, install_sent = false, installed = false, closed = false;
|
||||
bool tiles_done = false;
|
||||
const double started = now_s();
|
||||
double idle_since = started, installed_at = 0, last_serve_try = 0;
|
||||
double idle_since = started, installed_at = 0, last_serve_try = 0, last_tiles = 0;
|
||||
unsigned serve_failures = 0;
|
||||
/* The WebUI starts on a thread of its own (reading the library and the saved scraping
|
||||
* jobs takes seconds): the title's hello is answered meanwhile. 0 idle, 1 starting,
|
||||
@@ -368,6 +370,16 @@ int main()
|
||||
idle_since = 0;
|
||||
else if (idle_since == 0)
|
||||
idle_since = now;
|
||||
/* What a Game Forwarder tile waits for (a removal, a new name or picture, which
|
||||
* ShadowMountPlus refuses while a game runs): tried once no program of the title is
|
||||
* linked, and every few seconds after, not only at the stop kIdleSeconds later */
|
||||
if (linked)
|
||||
tiles_done = false; /* the page can leave more while the title runs */
|
||||
else if (serving && !tiles_done && now - last_tiles >= 3.0)
|
||||
{
|
||||
last_tiles = now;
|
||||
tiles_done = ps5_webui_finish_forwarders([](const char *line) { say("%s", line); });
|
||||
}
|
||||
|
||||
if (start_state == 2 || start_state == 3)
|
||||
{
|
||||
@@ -468,7 +480,7 @@ int main()
|
||||
close(listener);
|
||||
ps5_webui_stop();
|
||||
/* ShadowMountPlus refuses to uninstall while a game runs, and RetroArch is one */
|
||||
if (closed)
|
||||
if (closed && !tiles_done)
|
||||
ps5_webui_finish_forwarders([](const char *line) { say("%s", line); });
|
||||
say("stopped");
|
||||
return 0;
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"step": "game-forwarder-delete-on-close",
|
||||
"recorded": "2026-10-18T12:18:38Z",
|
||||
"console_run": "",
|
||||
"revision": "the commit after 001bbcf (the daemon's early tile work)",
|
||||
"command": "a tile made and installed, deleted through the WebUI while RetroArch ran, RetroArch closed through the control payload; /user/appmeta/<tile> polled once a second",
|
||||
"raw_capture": "klog/game-forwarder-delete-on-close/record.txt",
|
||||
"raw_lines": 13,
|
||||
"raw_bytes": 1735,
|
||||
"lines": [
|
||||
"==> a tile made and installed, deleted through the WebUI while RetroArch ran, RetroArch closed through the control payload; /user/appmeta/<tile> polled once a second",
|
||||
"Console: the owner's base PS5, firmware 13.40, ShadowMountPlus 1.7-beta5fix1, 2026-10-18, this build (the daemon tries a tile's pending work as soon as no program of the title is linked).",
|
||||
"The owner's report on 2026-10-18 (build 57fc257a): tiles deleted in the page were still on the home screen after closing RetroArch (from the home screen, and with the picker's Quit). That build's daemon log: the removals ran only at its stop, 20 s after the title closed (\"13:06:44 forwarder PPSA99170: removed\", \"13:06:45 forwarder PPSA99172: removed\"), and a title reopened within those 20 s left them for the next close; ShadowMountPlus then accepted both (\"uninstall requested\") and app.db and /user/appmeta held no trace of them.",
|
||||
"POST /api/forwarders (a SNES game): 201 {'id': 'PPSA99170'}; RetroArch closed: /user/appmeta/PPSA99170 there after 1.0 s",
|
||||
"RetroArch started; POST /api/forwarders/delete?id=PPSA99170: 200 {\"state\":\"pending\"}; GET /api/forwarders: PPSA99170 removing",
|
||||
"RetroArch closed: /user/appmeta/PPSA99170 gone after 3.0 s",
|
||||
"daemon: 13:18:48 unlinked: picker",
|
||||
"daemon: 13:18:48 forwarder PPSA99170: not removed yet: a game is active or awaiting exit; close the game and try again (game_active)",
|
||||
"daemon: 13:18:51 forwarder PPSA99170: removed",
|
||||
"daemon: 13:19:08 no program of the title linked for 20 s: the title is closed",
|
||||
"ShadowMountPlus: [09:18:51] [API] uninstall requested: title=PPSA99170",
|
||||
"ShadowMountPlus: [09:19:01] [CACHE] source removed: PPSA99170 (/data/homebrew/PPSA99170)",
|
||||
"Left on the console: no tile (/data/homebrew holds none of the range; manual.lst empty)."
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"step": "game-forwarder-delete-on-close",
|
||||
"note": "A tile deleted in the Game Forwarder page while RetroArch runs leaves the home screen seconds after RetroArch closes: the WebUI's daemon tries it as soon as no program of the title is linked and every 3 s while ShadowMountPlus refuses (the exiting app), instead of only at its stop 20 s later.",
|
||||
"must_contain": [
|
||||
"RetroArch closed: /user/appmeta/PPSA99170 gone after 3.0 s",
|
||||
"13:18:48 forwarder PPSA99170: not removed yet: a game is active or awaiting exit",
|
||||
"13:18:51 forwarder PPSA99170: removed",
|
||||
"[API] uninstall requested: title=PPSA99170"
|
||||
],
|
||||
"must_not_contain": [],
|
||||
"human_check": "The owner deletes a tile in the page while RetroArch runs, closes RetroArch (home screen or the picker's Quit): the tile leaves the home screen within a few seconds."
|
||||
}
|
||||
+10
-4
@@ -600,9 +600,10 @@ std::string remove(const Paths &paths, const std::string &id, std::string &why)
|
||||
return "removed";
|
||||
}
|
||||
|
||||
void finish_pending(const Paths &paths, void (*say)(const char *line))
|
||||
bool finish_pending(const Paths &paths, void (*say)(const char *line))
|
||||
{
|
||||
char line[512];
|
||||
size_t left = 0;
|
||||
std::vector<std::string> removals, refreshes;
|
||||
for (const auto &id : tile_ids(paths))
|
||||
{
|
||||
@@ -628,14 +629,14 @@ void finish_pending(const Paths &paths, void (*say)(const char *line))
|
||||
}
|
||||
}
|
||||
if (removals.empty() && refreshes.empty())
|
||||
return;
|
||||
return true;
|
||||
const Known smp = known(paths);
|
||||
if (!smp.answers)
|
||||
{
|
||||
std::snprintf(line, sizeof line, "forwarders: removals and new names or icons wait: %s",
|
||||
smp.why.c_str());
|
||||
say(line);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
std::vector<std::string> uninstalled;
|
||||
for (const auto &id : removals)
|
||||
@@ -644,8 +645,11 @@ void finish_pending(const Paths &paths, void (*say)(const char *line))
|
||||
"{\"title_id\":" + quote(id) + '}')
|
||||
: Json::parse("{\"status\":0}");
|
||||
if (!succeeded(gone))
|
||||
{
|
||||
++left;
|
||||
std::snprintf(line, sizeof line, "forwarder %s: not removed yet: %s", id.c_str(),
|
||||
failure(gone).c_str());
|
||||
}
|
||||
else
|
||||
{
|
||||
remove_tree(folder_of(paths, id));
|
||||
@@ -662,13 +666,14 @@ void finish_pending(const Paths &paths, void (*say)(const char *line))
|
||||
uninstalled.push_back(id);
|
||||
else
|
||||
{
|
||||
++left;
|
||||
std::snprintf(line, sizeof line, "forwarder %s: not re-registered yet: %s", id.c_str(),
|
||||
failure(gone).c_str());
|
||||
say(line);
|
||||
}
|
||||
}
|
||||
if (uninstalled.empty())
|
||||
return;
|
||||
return left == 0;
|
||||
/* the uninstall finishes on its own: then a scan installs each one again */
|
||||
for (int wait = 0; wait < 40; ++wait)
|
||||
{
|
||||
@@ -692,5 +697,6 @@ void finish_pending(const Paths &paths, void (*say)(const char *line))
|
||||
: "uninstalled; ShadowMountPlus did not take the scan");
|
||||
say(line);
|
||||
}
|
||||
return left == 0 && scanned;
|
||||
}
|
||||
} // namespace ps5_forwarders
|
||||
@@ -51,6 +51,7 @@ std::string icon_file(const Paths &paths, const std::string &id);
|
||||
/* "removed", "pending" (finished once the title has closed), or "" and why. */
|
||||
std::string remove(const Paths &paths, const std::string &id, std::string &why);
|
||||
/* Once the title has closed: removals and re-registrations that had to wait, and every
|
||||
* tile's app and payload brought up to this build's. say gets one line an action. */
|
||||
void finish_pending(const Paths &paths, void (*say)(const char *line));
|
||||
* tile's app and payload brought up to this build's. say gets one line an action. True
|
||||
* once nothing waits any more; false while ShadowMountPlus still refuses (a game runs). */
|
||||
bool finish_pending(const Paths &paths, void (*say)(const char *line));
|
||||
} // namespace ps5_forwarders
|
||||
+3
-3
@@ -2703,8 +2703,8 @@ void ps5_webui_stop()
|
||||
ps5_scraper::shutdown();
|
||||
ps5_update::stop();
|
||||
}
|
||||
void ps5_webui_finish_forwarders(void (*say)(const char *line))
|
||||
bool ps5_webui_finish_forwarders(void (*say)(const char *line))
|
||||
{
|
||||
if (!root_path.empty())
|
||||
ps5_forwarders::finish_pending(forwarder_paths(), say);
|
||||
std::lock_guard<std::mutex> guard(state_lock); // the page may be open meanwhile
|
||||
return root_path.empty() || ps5_forwarders::finish_pending(forwarder_paths(), say);
|
||||
}
|
||||
+4
-3
@@ -5,9 +5,10 @@
|
||||
// before RetroArch starts (eboot.bin), never from the daemon while a core may run.
|
||||
bool ps5_webui_start(const char *root, unsigned short port = 6769, bool apply_settings = true);
|
||||
void ps5_webui_stop();
|
||||
// Once the title has closed (the daemon, after ps5_webui_stop): what the Game Forwarder
|
||||
// page's tiles left for then, removals and new names or icons (src/ps5_forwarders.h).
|
||||
void ps5_webui_finish_forwarders(void (*say)(const char *line));
|
||||
// Once no program of the title is linked (the daemon): what the Game Forwarder page's
|
||||
// tiles left for the title's close, removals and new names or icons
|
||||
// (src/ps5_forwarders.h). True once nothing waits any more.
|
||||
bool ps5_webui_finish_forwarders(void (*say)(const char *line));
|
||||
// The title's side when the daemon serves (src/webui_link.h): the folder core option
|
||||
// metadata is written to, and the saved core settings applied before RetroArch reads them.
|
||||
void ps5_webui_prepare(const char *root);
|
||||
|
||||
@@ -3,18 +3,22 @@ programs as src/webui_link.cpp links, it serves the title's folder, names the fr
|
||||
keeps an upload going while the title changes frontend (one program's link closes,
|
||||
the next one's opens), refuses another build's programs, and stops once the title has
|
||||
closed. Its folder, ports and idle time are the test's."""
|
||||
from http.server import ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
|
||||
import sys
|
||||
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent))
|
||||
import webui_build # noqa: E402
|
||||
from test_webui_forwarders import ShadowMount # noqa: E402
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
TITLE = 'PPSA99169'
|
||||
@@ -49,9 +53,15 @@ class WebuiDaemon(unittest.TestCase):
|
||||
(f'PS5_WEBUI_HOMEBREW="{cls.homebrew}"', f'PS5_WEBUI_LINK_PORT={cls.link_port}',
|
||||
f'PS5_WEBUI_HTTP_PORT={cls.http_port}', f'PS5_WEBUI_IDLE_SECONDS={IDLE}'),
|
||||
('-O1', '-g', '-Wall', '-Wextra', '-Werror'))
|
||||
# ShadowMountPlus, for the Game Forwarder tiles the daemon finishes (src/ps5_forwarders.h)
|
||||
cls.smp = ThreadingHTTPServer(('127.0.0.1', 0), ShadowMount)
|
||||
cls.smp.state = {'calls': [], 'down': False, 'busy': False, 'scans': 0, 'manual': set(), 'games': {}}
|
||||
threading.Thread(target=cls.smp.serve_forever, daemon=True).start()
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.smp.shutdown()
|
||||
cls.smp.server_close()
|
||||
cls.temp.cleanup()
|
||||
|
||||
def setUp(self):
|
||||
@@ -62,7 +72,8 @@ class WebuiDaemon(unittest.TestCase):
|
||||
(self.root / 'webui/index.html').write_text('<!doctype html><title>RetroArch</title>')
|
||||
(self.root / 'webui/version.json').write_text('{"build": "one"}\n')
|
||||
(self.root / 'sce_sys/param.json').write_text(json.dumps({'titleId': TITLE}))
|
||||
self.daemon = subprocess.Popen([str(self.binary)], stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True)
|
||||
self.daemon = subprocess.Popen([str(self.binary)], stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True,
|
||||
env={**os.environ, 'PS5_FORWARDERS_SHADOWMOUNT_PORT': str(self.smp.server_port)})
|
||||
self.addCleanup(self.stop)
|
||||
self.assertTrue(wait_for(self.listening), 'the daemon did not listen for links')
|
||||
|
||||
@@ -152,6 +163,31 @@ class WebuiDaemon(unittest.TestCase):
|
||||
'the title is closed', 'stopped'):
|
||||
self.assertIn(line, log)
|
||||
|
||||
def test_a_tile_removal_is_done_as_soon_as_the_title_closes(self):
|
||||
# A Game Forwarder tile deleted while RetroArch ran (src/ps5_forwarders.h): nothing
|
||||
# while a program is linked, the uninstall as soon as none is, before the stop.
|
||||
tile = self.homebrew / 'PPSA99170'
|
||||
self.addCleanup(shutil.rmtree, tile, True)
|
||||
(tile / 'sce_sys').mkdir(parents=True)
|
||||
(tile / 'forward.txt').write_text('--rom\nsnes/Game.zip\n')
|
||||
(tile / 'remove.pending').write_text('')
|
||||
state = self.smp.state
|
||||
state['games']['PPSA99170'] = True
|
||||
calls = len(state['calls'])
|
||||
link, reply = self.hello('retroarch')
|
||||
self.assertEqual(reply, 'ok')
|
||||
self.assertTrue(wait_for(self.serving))
|
||||
time.sleep(1)
|
||||
self.assertTrue(tile.is_dir())
|
||||
self.assertNotIn(('/api/v1/games/uninstall', {'title_id': 'PPSA99170'}), state['calls'][calls:])
|
||||
link.close()
|
||||
closed = time.monotonic()
|
||||
self.assertTrue(wait_for(lambda: not tile.exists(), timeout=IDLE + 10))
|
||||
self.assertLess(time.monotonic() - closed, IDLE - 0.5)
|
||||
self.assertIn(('/api/v1/games/uninstall', {'title_id': 'PPSA99170'}), state['calls'][calls:])
|
||||
self.assertEqual(self.daemon.wait(timeout=IDLE + 10), 0)
|
||||
self.assertIn('forwarder PPSA99170: removed', self.daemon.stderr.read())
|
||||
|
||||
def test_other_build_and_other_title(self):
|
||||
first, reply = self.hello('picker')
|
||||
self.assertEqual(reply, 'ok')
|
||||
|
||||
@@ -33,7 +33,8 @@ int main(int argc, char **argv)
|
||||
paths.homebrew = argv[1];
|
||||
paths.title = argv[2];
|
||||
paths.shadowmount_port = static_cast<unsigned short>(std::atoi(argv[3]));
|
||||
ps5_forwarders::finish_pending(paths, [](const char *line) { std::puts(line); });
|
||||
if (ps5_forwarders::finish_pending(paths, [](const char *line) { std::puts(line); }))
|
||||
std::puts("nothing waits");
|
||||
}
|
||||
'''
|
||||
|
||||
@@ -224,10 +225,14 @@ class Forwarders(unittest.TestCase):
|
||||
self.assertNotIn(str(folder), state['manual'])
|
||||
self.assertTrue((folder / 'remove.pending').exists())
|
||||
self.assertEqual(next(t for t in self.tiles()['tiles'] if t['id'] == tile_id)['state'], 'removing')
|
||||
self.assertIn(f'forwarder {tile_id}: not removed yet: A game is active (game_active)', self.run_finish())
|
||||
output = self.run_finish()
|
||||
self.assertIn(f'forwarder {tile_id}: not removed yet: A game is active (game_active)', output)
|
||||
self.assertNotIn('nothing waits', output)
|
||||
self.assertTrue(folder.exists())
|
||||
state['busy'] = False
|
||||
self.assertIn(f'forwarder {tile_id}: removed', self.run_finish())
|
||||
output = self.run_finish()
|
||||
self.assertIn(f'forwarder {tile_id}: removed', output)
|
||||
self.assertIn('nothing waits', output)
|
||||
self.assertFalse(folder.exists())
|
||||
self.assertFalse(state['games'][tile_id])
|
||||
self.assertNotIn(tile_id, [t['id'] for t in self.tiles()['tiles']])
|
||||
@@ -271,6 +276,7 @@ class Forwarders(unittest.TestCase):
|
||||
output = self.run_finish()
|
||||
self.assertIn(f'forwarder {tile_id}: launch.elf brought up to this build', output)
|
||||
self.assertIn(f'forwarder {tile_id}: installed again with its new name or icon', output)
|
||||
self.assertIn('nothing waits', output)
|
||||
self.assertEqual((folder / 'launch.elf').read_bytes(), b'launch payload')
|
||||
self.assertFalse((folder / 'refresh.pending').exists())
|
||||
self.assertEqual(state['scans'], scans + 1)
|
||||
|
||||
Reference in new issue
Block a user