MemDBG's reconnect system enables the frontend to survive console rest-mode cycles — the connection is temporarily lost, the console enters low-power sleep, and when it wakes the frontend automatically reconnects without destroying the user's work (scan results, trainer definitions, UI state).
When a PlayStation 4 or 5 enters rest mode, the payload daemon may be:
- Terminated — the payload process dies and must be re-injected.
- Suspended — the process survives but loses its network sockets.
- Suspended with stale sockets — the process and socket FDs survive, but the console's network stack is temporarily unavailable.
The reconnect system handles all three cases through a layered design:
| Layer | File(s) | Responsibility |
|---|---|---|
| State machine | app_state.hpp (enums, structs), session.cpp (transitions) |
Detects transport loss, tracks reconnect phase, preserves UI state |
| Connection lifecycle | connection.cpp (quiesce_transport, reset_remote_session, connect_console) |
Separates non-destructive quiesce from full session teardown |
| Epoch protocol | app_state.hpp, scanner_async.cpp, screen_processes.cpp |
Prevents stale async results from old connections |
| Session restoration | session.cpp (poll_restore_session, TargetIdentity) |
Rematches the target process after reconnect using logical identity |
| Daemon identity | memdbg_protocol.h (HELLO v2), response.c |
daemon_instance_id detects payload restart vs survival |
enum class ConnectionPhase {
Disconnected, // No active session (initial state or manual disconnect)
Connecting, // Async connect in progress (manual → spinner modal shown)
Online, // Fully connected, remote operations allowed
ConnectionLost, // Transport detected dead (heartbeat failed)
WaitingForWake, // Backoff timer counting down before next retry
Reconnecting, // Async reconnect attempt in progress (no modal)
Restoring, // Reconnected — refreshing process list & rematching target
}; Disconnected
│
▼ (user clicks Connect, or payload auto-inject completes)
Connecting ──────────────► Online
│ │
│ (failure / cancel) │ (heartbeat ×2 fails)
▼ ▼
Disconnected ConnectionLost
│
▼
WaitingForWake ── (user cancels) ──► Disconnected
│
│ (backoff timer expires)
▼
Reconnecting ── (user cancels) ──► Disconnected
│
▼ (HELLO succeeds)
Restoring
│ (process list refreshed, target rematched)
▼
Online
struct ReconnectState {
bool enabled = true; // Can be disabled via settings
bool manual_disconnect = false; // User explicitly disconnected → no auto-reconnect
bool stale = false; // Remote state (PID, maps, debugger) is suspect
ConnectionPhase phase{Disconnected};
uint32_t attempt = 0; // Consecutive retry count
uint64_t epoch = 0; // Bumped on every disconnect cycle
steady_clock::time_point next_attempt_at{};
steady_clock::time_point started_at{};
std::string reason;
TargetIdentity target_identity; // Saved before disconnect for rematch
};Key design decisions:
std::chrono::steady_clock(notImGui::GetTime()): immune to computer sleep. If the frontend machine itself suspends, the backoff timer doesn't drift.manual_disconnectgate: user-initiated disconnects are permanent. Only transport-level failures trigger auto-reconnect.staleflag: settrueon disconnect, cleared tofalseonly after completedRestoringphase. Whilestale,remote_ready()returnsfalse.
The frontend detects transport loss through two consecutive heartbeat failures
(heartbeat_failures >= 2). A single timeout is ignored — it may be caused by
a heavy scan, network congestion, or a temporary kernel stall.
Heartbeats are suspended during long-running operations (scans, dumps) to avoid
false positives. The heartbeat_pending flag gates the health check.
Attempt 0: 500 ms
Attempt 1: 1000 ms
Attempt 2: 2000 ms
Attempt 3: 4000 ms
Attempt 4: 8000 ms
Attempt 5+: 10000 ms (repeats indefinitely)
Defined in connection.cpp:
static constexpr std::array<milliseconds, 6> kBackoff{
milliseconds(500), milliseconds(1000), milliseconds(2000),
milliseconds(4000), milliseconds(8000), milliseconds(10000)};Called when transport loss is detected (heartbeat failure ×2 or socket error).
- Saves
TargetIdentityfrom the currently selected process. - Cancels all pending I/O, disconnects control socket, invalidates role sockets.
- Bumps
reconnect.epoch— all in-flight async operations from the old epoch are now stale. - Sets
phase = WaitingForWake, initializes backoff timer.
void begin_reconnect(AppState &state, const std::string &reason);Called every frame by draw_app(). Checks for transport-level failure:
if (state.has_hello && !state.client.connected() &&
!state.conn.connect_pending &&
state.conn.reconnect.phase == ConnectionPhase::Online) {
begin_reconnect(state, ...);
}Critical guard: the phase == Online check prevents re-triggering
begin_reconnect every frame after the first failure (which would reset
the backoff timer infinitely).
Called every frame, immediately after poll_session_health(). Advances the
state machine when the backoff timer expires:
if (phase != WaitingForWake && phase != Reconnecting) return;
if (now < next_attempt_at) return;
phase = Reconnecting;
connect_console(state, ConnectIntent::AutomaticReconnect);Collects the result of the async connect_console() future:
- Success on reconnect path: enters
Restoringphase. Debugger and tracer state are invalidated (attachments tied to old PID). Daemon instance ID is checked for rotation. - Success on manual connect: enters
Onlinedirectly (fresh session). - Failure on reconnect: calls
schedule_reconnect_retry()which updates the status bar with current attempt and retry seconds. - Cancelled: stops the reconnect state machine (
manual_disconnect = true,phase = Disconnected).
Cancels any in-flight connection attempt. If the reconnect state machine is
active (WaitingForWake or Reconnecting), this permanently stops it:
void cancel_connect(AppState &state) {
state.conn.reconnect.manual_disconnect = true;
state.conn.reconnect.phase = ConnectionPhase::Disconnected;
state.conn.reconnect.reason.clear();
state.conn.reconnect.attempt = 0;
state.conn.connect_cancel_requested = true;
++state.conn.connect_generation;
}Once manual_disconnect is true, begin_reconnect() and poll_reconnect()
are both gated — the state machine will not restart until the user manually
connects again.
| Operation | quiesce_transport() |
reset_remote_session() |
|---|---|---|
| Cancel pending I/O | ✅ | ✅ |
| Disconnect sockets | ✅ | ✅ |
| Clear process list | ❌ | ✅ |
| Clear maps | ❌ | ✅ |
| Reset scanner results | ❌ | ✅ |
| Reset debugger state | ❌ | ✅ |
| Preserve trainer definitions | ✅ | ❌ |
| Preserve scan results | ✅ | ❌ |
| Preserve UI state | ✅ | ❌ |
| Called on | transport loss (reconnect) | manual disconnect, shutdown |
Called every frame while phase == Restoring. Does not independently
request a process list — it polls taskmgr.prefetch_future, which was already
launched by poll_connect() falling through to start_taskmgr_prefetch()
after setting phase = Restoring.
poll_connect() connection.cpp
success on reconnect path
→ phase = Restoring
→ falls through to start_taskmgr_prefetch() ← spawns async process list
poll_restore_session() session.cpp
polls taskmgr.prefetch_future ← collects the result
restore_list_requested flag: A bool in AppState that gates the forced
process list refresh. Set to true on the first Restoring frame (the flag
is initially false, so the first call triggers the refresh). Cleared to
false when the phase leaves Restoring. This prevents re-requesting the
list every frame.
Rematching logic:
- Rematches the target process by
TargetIdentity:- Primary:
title_idmatch viataskmgr.resources(requires process info). - Fallback:
namematch against the process list.
- Primary:
- On match: refreshes memory maps, sets
phase = Online, clearsstale. - On no match: sets
phase = Onlinebut leavesstale = true— the user must manually re-select the target process on the new daemon instance.
PIDs are ephemeral across rest-mode cycles (the game process gets a new PID on
wake). TargetIdentity stores the logical identity of the target so the
frontend can rematch it:
struct TargetIdentity {
std::string name; // "eboot.bin", "SceShellCore"
std::string title_id; // "CUSA12345"
std::string content_id; // "IV0001-CUSA12345_00-MEMDBG0000000000"
std::string executable_path; // "/app0/eboot.bin"
std::string selected_module_name;
uint64_t selected_module_offset = 0;
bool valid() const { return !name.empty() || !title_id.empty(); }
};Captured in begin_reconnect() before the socket is closed, used in
poll_restore_session() for rematching.
Every disconnect cycle bumps reconnect.epoch. Async operations capture the
epoch at launch and reject their results if the epoch has changed by the time
they complete.
| Operation | Epoch field | File |
|---|---|---|
| Telemetry | telemetry_epoch |
connection.cpp |
| Map refresh | map_refresh_epoch |
connection.cpp |
| TaskMgr prefetch | taskmgr.prefetch_epoch |
connection.cpp |
| Scanner (all 4 variants) | scan.async_epoch |
scanner_async.cpp |
| Map dump | map_dump_epoch |
screen_processes.cpp |
// Launch: capture epoch
state.scan.async_epoch = state.conn.reconnect.epoch;
// Poll: reject if stale
const uint64_t captured_epoch = state.scan.async_epoch;
state.scan.async_pending = false;
if (captured_epoch != state.conn.reconnect.epoch) {
// Clear temp storage and consume the future, do NOT apply results
state.scan.async_future.get();
state.scan.async_temp_result = ScanResult{};
state.scan.async_temp_snapshot.clear();
return;
}
// Apply results...New remote operations should check remote_ready() before launching:
inline bool remote_ready(const AppState &state) {
return state.client.connected() &&
state.conn.reconnect.phase == ConnectionPhase::Online &&
!state.conn.reconnect.stale;
}This is defined in app_state.hpp and blocks operations while: disconnected,
reconnecting, restoring, or when remote state is stale.
The HELLO response includes a daemon_instance_id (random uint64 generated at
daemon startup) and daemon_start_monotonic_ns. These allow the frontend to
distinguish:
| Scenario | daemon_instance_id |
Action |
|---|---|---|
| Payload survived rest mode | Same | Preserve state, revalidate processes/maps |
| Payload was terminated & re-injected | Different | Clear processes, maps, invalidate all remote handles |
| Pre-v2 payload | Zero | Degrade gracefully (skip rotation check) |
The daemon_instance_id is checked in poll_connect() after a successful
reconnect HELLO.
In draw_app() (shell/memdbg_app.cpp), the poll functions are called in
a specific order:
poll_connect(state); // Collect async connect result
poll_payload_lifecycle(state); // Payload inject → auto-connect flow
poll_session_health(state); // Detect transport loss → begin_reconnect
poll_reconnect(state); // Advance backoff timer → connect_console(Auto)
poll_restore_session(state); // Rematch target process during Restoring phase
// Remaining periodic tasks (only launch when remote_ready)
poll_taskmgr_prefetch(state);
poll_telemetry(state);
poll_map_refresh(state);
poll_tracer(state);
poll_plugin_tasks(state);
poll_cheat_tasks(state);Key ordering: poll_session_health must run before poll_reconnect —
the first detects the loss, the second advances the backoff state machine.
- Current screen and UI state
- Console IP/port settings
- Trainer and cheat definitions
- Scan results (marked as unverified —
stale = true) - Scanner history and snapshots
- Target process name, title ID, content ID
- Selected module name and offset
- PID (numeric) — always re-resolved
- Debugger attachment, breakpoints, watchpoints
- Tracer state
- Thread IDs
- Remote allocation handles
- Server-side scan jobs
- Memory maps (refreshed from daemon)
- Absolute addresses (recalculated from module base + offset)
- Trainer lock writes and periodic writes
- Breakpoint re-enable
- Watchpoint re-enable
- Tracer re-attach
Note: Trainer write suspension is implemented (PR 3 —
apply_locked_cheats()checksremote_ready()). Automatic byte-verification before re-enabling breakpoints/watchpoints/tracer is planned for PR 4.
| Test | File | What it validates |
|---|---|---|
| Reconnect E2E | tests/test_reconnect_e2e.c |
Daemon restart, HELLO v2, instance_id rotation, double cycle |
| State machine E2E | tests/test_reconnect_state_machine.c |
Full phase transition cycle: Online → ConnectionLost → WaitingForWake → Reconnecting → Restoring → Online, heartbeat ×2, second cycle stress |
| Frontend state machine | frontend/tests/test_app_reconnect.cpp |
116 unit tests: ConnectionPhase transitions, TargetIdentity, remote_ready, epoch stale rejection, backoff timing, trainer suspension |
Run with:
make test-reconnect-e2e
make test-reconnect-state-machine
cd frontend && cmake --build build --target memdbg_app_reconnect_test && ./build/bin/memdbg_app_reconnect_test- PR 4: Payload network supervisor — indefinite retry with backoff (currently gives up after 15 seconds). Listener-liveness probe for the case where socket FDs survive rest mode but the network stack is down.
- PR 5: Additional integration tests —
test_server_restart_same_instance,test_stale_future_rejection,test_listener_recreation,test_reconnect_fd_leaks. - Hardware validation: 50 cycles PS5 rest → wake, 20 cycles PS4 rest → wake.