UPnP: fix the exit hang and the CPU spin, and only run the thread when enabled

The two long-standing bug reports had a shared root: the service loop could
end up in a state it never left.

- Exit hang: UPNP_CMD_EXIT was queued alongside port requests and only acted
  on when it reached the front. A request that couldn't complete was never
  popped, so exit sat behind it forever and join() blocked indefinitely.
  Exit is a flag now, checked before anything else.
- CPU spin: wait_for() with a predicate returns immediately when the predicate
  already holds, so a stuck queue head meant a tight loop. sceNetInet's bind()
  queues UPnP_Add regardless of the setting, so this hit whenever UPnP was off
  and a game used sockets. The loop always blocks now, and requests are dropped
  while UPnP is off.
- Failed discovery was retried every 5s forever, each time a full 2s SSDP round
  plus an error toast. Now backs off 5s -> 300s and reports once.

Other things found while in here:

- Every failed Initialize() leaked a UPNPUrls + IGDdatas, so ~every 5 seconds
  for anyone with UPnP on and no router. The manual miniwget/parserootdesc/
  GetUPNPUrls block was also redundant - UPNP_GetValidIGD does all of it and
  memsets over the result, leaking the URLs and costing an extra HTTP round
  trip per attempt.
- UPNP_GetValidIGD's status was never checked, so we could go DONE with no
  usable IGD and hand a NULL controlURL to UPNP_GetConnectionTypeInfo.
- miniupnpc's strncpy into the port-mapping-entry buffers doesn't guarantee a
  terminator; an 80-char description ran std::string off the end of desc[80].
- Add() marked another app's port "taken" only after our own add succeeded, so
  a failed add left their mapping deleted and never restored.
- Clear() walked the router's entire table at exit, one HTTP round trip per
  index. It now deletes only what we know we mapped, and the exit cleanup has
  a time budget so an unreachable router can't stall shutdown.
- The in-flight request stayed in the queue during the router call, so a
  same-port request arriving concurrently could erase it and be dropped
  unexecuted.
- The queue is bounded, and last-write-wins per port collapses the churn from
  games that rebind in a loop.
- The mapping description is built when the request is queued rather than read
  off g_paramSFO from the UPnP thread later.

The thread now only exists while the setting is on - turning it off makes it
remove its mappings and exit, turning it on starts one. That means __UPnPInit()
has to run after the config is actually loaded; g_Config.Init() only builds a
lookup table. QueueRequest() reconciles too, so a per-game config or a libretro
core option enabling UPnP works without a notify at every call site.

The settings checkbox is disabled in-game, since sceNet latches related
settings at boot and a game that already mapped its ports wouldn't cope with
them disappearing.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01DCPmm7FoQUoqrbMdhfqhQ2
This commit is contained in:
Henrik RydgårdandClaude Opus 5 committed 2026-08-30 00:37:05 +02:00
1 parent f89a2d4199
commit 2cb0be1bc4
5 files changed
+590 -427

No files matched your search

+4
View File
@@ -626,6 +626,10 @@ void __NetInit() {
#ifdef __LIBRETRO__
__UPnPInit(2000);
#endif
// The UPnP service thread only exists while UPnP is enabled, and a per-game config (or a
// libretro core option) can turn it on after startup. Kick it here so discovery is already
// done by the time the game binds its first socket, rather than starting on that request.
UPnP_Notify();
__ResetInitNetLib();
__NetApctlInit();
+533 -389
View File
File diff suppressed because it is too large. Load diff
+41 -30
View File
@@ -37,13 +37,6 @@
#include <string>
#include <deque>
struct UPnPArgs {
int cmd;
std::string protocol;
unsigned short port;
unsigned short intport;
};
#define IP_PROTOCOL_TCP "TCP"
#define IP_PROTOCOL_UDP "UDP"
@@ -56,11 +49,20 @@ enum {
enum {
UPNP_CMD_ADD = 0,
UPNP_CMD_REMOVE = 1,
UPNP_CMD_EXIT = 2,
};
struct UPNPUrls;
struct IGDdatas;
struct UPnPArgs {
int cmd = UPNP_CMD_ADD;
std::string protocol;
unsigned short port = 0;
unsigned short intport = 0;
// Description to register the mapping under. Built when the request is queued, on the thread
// that owns the game state, since the UPnP service thread can't safely read it later.
std::string desc;
// How many times we've failed to reach the router about this request. Bounded so a request
// can't get retried forever, blocking everything queued behind it.
int attempts = 0;
};
struct PortMap {
bool taken;
@@ -74,29 +76,33 @@ struct PortMap {
std::string enabled;
};
// Only ever touched by the UPnP service thread (see PortManager.cpp). Don't call into it
// from anywhere else - queue a request with UPnP_Add()/UPnP_Remove() instead.
class PortManager {
public:
// Initialize UPnP
// timeout: milliseconds to wait for a router to respond (default = 2000 ms)
bool Initialize(const unsigned int timeout = 2000);
// Discover a router and pick up any mappings we left behind earlier.
// timeout: milliseconds to wait for a router to respond.
bool Initialize(unsigned int timeout = 2000);
// Get UPnP Initialization status
int GetInitState();
int GetInitState() const { return m_InitState; }
// Add a port & protocol (TCP, UDP or vendor-defined) to map for forwarding (intport = 0 : same as [external] port)
bool Add(const char* protocol, unsigned short port, unsigned short intport = 0);
bool Add(const char *protocol, unsigned short port, unsigned short intport, const std::string &desc);
// Remove a port mapping (external port)
bool Remove(const char* protocol, unsigned short port);
bool Remove(const char *protocol, unsigned short port);
// Call on exit. Does a full shutdown.
void Shutdown();
// Drops our mappings, restores any that we took over, and resets to the uninitialized state.
// budgetSeconds bounds how long we're willing to keep talking to the router: a router that has
// gone away answers with socket timeouts, which would otherwise stall app exit for a long time.
void Shutdown(double budgetSeconds = 3.0);
private:
// Retrieves port lists mapped by PPSSPP for current LAN IP & other's applications
bool RefreshPortList();
// Removes any lingering mapped ports created by PPSSPP (including from previous crashes)
// Removes the port mappings we know PPSSPP created (including leftovers from previous crashes,
// which RefreshPortList() picks up at init time).
bool Clear();
// Restore ports mapped by others that were taken by PPSSPP, better used after Clear()
@@ -105,30 +111,35 @@ private:
// Uninitialize/Reset the state
void Terminate();
struct UPNPUrls* urls = nullptr;
struct IGDdatas* datas = nullptr;
bool HaveControlURL() const;
// True once the current operation has used up its time budget, see Shutdown().
bool OutOfTime() const;
UPNPUrls m_urls{};
IGDdatas m_datas{};
bool m_urlsValid = false;
int m_InitState = UPNP_INITSTATE_NONE;
int m_LocalPort = UPNP_LOCAL_PORT_ANY;
double m_deadline = 0.0;
std::string m_lanip;
std::string m_defaultDesc;
std::string m_leaseDuration = "43200"; // range(0-604800) in seconds (0 = Indefinite/permanent). Some routers doesn't support non-zero value
std::string m_leaseDuration;
std::deque<std::pair<std::string, std::string>> m_portList;
std::deque<PortMap> m_otherPortList;
};
extern PortManager g_PortManager;
void __UPnPInit(const unsigned int timeout_ms);
void __UPnPInit(unsigned int timeout_ms);
void __UPnPShutdown();
// Add a port & protocol (TCP, UDP or vendor-defined) to map for forwarding (intport = 0 : same as [external] port)
void UPnP_Add(const char* protocol, unsigned short port, unsigned short intport = 0);
void UPnP_Add(const char *protocol, unsigned short port, unsigned short intport = 0);
// Remove a port mapping (external port)
void UPnP_Remove(const char* protocol, unsigned short port);
void UPnP_Remove(const char *protocol, unsigned short port);
// Wakes the UPnP service thread immediately, without queuing a request - useful after
// changing the enable setting or similar, so it can (re)connect without waiting for the
// periodic retry.
// Wakes the UPnP service thread immediately, without queuing a request - call this after
// changing the enable setting so it can connect (or tear down its mappings) right away
// instead of waiting for the next retry.
void UPnP_Notify();
+7 -3
View File
@@ -1049,9 +1049,13 @@ void GameSettingsScreen::CreateNetworkingSettings(UI::ViewGroup *networkingSetti
dnsServer->SetDisabledPtr(&g_Config.bInfrastructureAutoDNS);
networkingSettings->Add(new ItemHeader(n->T("UPnP (port-forwarding)")));
networkingSettings->Add(new CheckBox(&g_Config.bEnableUPnP, n->T("Enable UPnP", "Enable UPnP (need a few seconds to detect)")))->OnClick.Add([](UI::EventParams &e) {
// Wake the UPnP service thread immediately so it reacts to the new setting instead
// of waiting for the next periodic retry (or a port request that may never come).
// Only togglable outside a game - sceNet latches settings like UPnPUseOriginalPort at boot,
// and a game that's already mapped its ports wouldn't cope with them disappearing.
CheckBox *enableUPnP = networkingSettings->Add(new CheckBox(&g_Config.bEnableUPnP, n->T("Enable UPnP", "Enable UPnP (need a few seconds to detect)")));
enableUPnP->SetEnabled(!PSP_IsInited());
enableUPnP->OnClick.Add([](UI::EventParams &e) {
// Wake the UPnP service thread so it connects (or tears its mappings back down) right
// away, instead of waiting for a port request that may never come.
UPnP_Notify();
});
auto *useOriPort = networkingSettings->Add(new CheckBox(&g_Config.bUPnPUseOriginalPort, n->T("UPnP use original port", "UPnP use original port (Enabled = PSP compatibility)")));
+5 -5
View File
@@ -515,9 +515,6 @@ void NativeInit(int argc, const char *argv[], const CommandLineOptions &cmdLineO
IncrementDebugCounter(DebugCounter::APP_BOOT);
// Probably an excessive timeout. it only causes delays on shutdown, though.
__UPnPInit(2000);
ShaderTranslationInit();
g_threadManager.Init(cpu_info.num_cores, cpu_info.logical_cpu_count);
@@ -704,6 +701,11 @@ void NativeInit(int argc, const char *argv[], const CommandLineOptions &cmdLineO
g_Config.LoadAppendedConfig();
}
// Has to be after the config is loaded: it only starts a service thread if UPnP is enabled,
// and g_Config.Init() above doesn't read the ini, it just builds a lookup table.
// Probably an excessive timeout. It only causes delays on shutdown, though.
__UPnPInit(2000);
// This parameter should be a boot filename. Only accept it if we
// don't already have one.
if (!cmdLineOptions.bootFilenames.empty()) {
@@ -1851,8 +1853,6 @@ void NativeShutdown() {
__UPnPShutdown();
g_PortManager.Shutdown();
net::Shutdown();
g_Discord.Shutdown();