[GH-ISSUE #3078] Сlient 5.0.1.5 gets cached after SLEEP or HIBERNATION #1423

Open
opened 2026-10-13 20:42:13 +00:00 by bsakaguchi · 1 comment
Owner

Originally created by @Henry-White on GitHub (Aug 28, 2026).
Original GitHub issue: https://github.com/amnezia-vpn/amnezia-client/issues/3078

After the computer enters SLEEP or HIBERNATION, the client gets cached, while the tune2socks interface remains active and has to be killed manually (otherwise the client will not restart).

Windows 11
5.0.1.5

Originally created by @Henry-White on GitHub (Aug 28, 2026). Original GitHub issue: https://github.com/amnezia-vpn/amnezia-client/issues/3078 After the computer enters SLEEP or HIBERNATION, the client gets cached, while the tune2socks interface remains active and has to be killed manually (otherwise the client will not restart). Windows 11 5.0.1.5
Author
Owner

@HeisLuka commented on GitHub (Sep 9, 2026):

I checked this against both 5.0.1.5 and current dev.

The reported behavior is consistent with a source-level Windows lifecycle race, and there is also a concrete bug in the stale-tun2socks cleanup path.

1. Windows handles resume, but not pre-suspend teardown

client/platforms/windows/windowsnetworkwatcher.cpp currently handles:

case WM_POWERBROADCAST:
    if (wParam == PBT_APMRESUMESUSPEND) {
        emit obj->wakeup();
    }
    break;

There is no corresponding handling of PBT_APMSUSPEND.

Therefore an active XRay/tun2socks data plane is not explicitly quiesced before Windows enters sleep/hibernation.

After resume, wakeup is forwarded through the service and connected directly to:

VpnConnection::reconnectToVpn()

which performs:

setConnectionState(Vpn::ConnectionState::Reconnecting);

m_vpnProtocol->stop();
m_vpnProtocol->start();

So resume effectively means:

old session
    ↓
stop()
    ↓
immediately start a new session

The correctness of resume therefore depends on stop() providing a strict resource-release barrier.

For XRay/tun2socks on Windows, it currently does not.

2. Windows XrayProtocol::stop() does not wait for tun2socks to exit

The relevant code is in:

client/core/protocols/xrayProtocol.cpp

Non-Windows builds do:

m_tun2socksProcess->terminate();

auto waitForFinished =
    m_tun2socksProcess->waitForFinished(1000);

if (...) {
    m_tun2socksProcess->kill();
}

but the Windows path is:

#else
    // terminate does not do anything useful on Windows
    // so just kill the process
    m_tun2socksProcess->kill();
#endif

m_tun2socksProcess->close();
m_tun2socksProcess.reset();

There is no waitForFinished() after kill() on Windows.

Therefore stop() can return and reconnectToVpn() can call start() without a confirmed postcondition that the previous tun2socks.exe has exited and released its TUN resources.

3. deleteTun() does not provide that barrier on Windows either

XrayProtocol::stop() calls:

iface->deleteTun(tunName);

before killing tun2socks.

However service/server/router.cpp currently implements:

bool Router::deleteTun(const QString &dev)
{
#ifdef Q_OS_LINUX
    return RouterLinux::Instance().deleteTun(dev);
#endif
#ifdef Q_OS_MAC
    return RouterMac::Instance().deleteTun(dev);
#endif
    return true;
}

There is no Windows implementation.

So on Windows deleteTun() simply reports success without releasing the TUN.

The Windows TUN is effectively tied to the tun2socks process lifetime, which makes confirmed process termination even more important.

4. The fallback intended to kill an old tun2socks before starting a new one is broken on Windows

This is the most concrete problem I found.

Every privileged process start currently does:

void IpcServerProcess::start()
{
    ...
    Utils::killProcessByName(m_process->program());
    m_process->start();
    ...
}

For tun2socks, m_process->program() is set from:

Utils::tun2socksPath()

which returns:

Utils::executable("tun2socks", true);

On Windows, Utils::executable(..., true) returns an absolute path, conceptually:

C:\Program Files\AmneziaVPN\tun2socks.exe

But Utils::killProcessByName() enumerates processes using PROCESSENTRY32W and compares:

QString exeFile =
    QString::fromWCharArray(pe32.szExeFile);

if (exeFile.compare(name, Qt::CaseInsensitive) == 0) {
    ...
}

pe32.szExeFile contains the executable name:

tun2socks.exe

while name is the absolute program path:

C:\...\tun2socks.exe

Thus the comparison does not match.

The intended startup safeguard:

kill stale tun2socks
→ start new tun2socks

effectively becomes:

search for process named
"C:\...\tun2socks.exe"

running process name is
"tun2socks.exe"

→ no match
→ stale process is not terminated
→ start another tun2socks

This matches the report particularly well: manually terminating the old tun2socks.exe makes the client able to start again.

5. The service also retains privileged process descriptors

IpcServer::createPrivilegedProcess() stores each privileged process in:

m_processes.insert(m_localpid, pd);

where the service-side ProcessDescriptor owns the real IpcServerProcess/QProcess.

The desktop m_tun2socksProcess object is only a Qt Remote Objects replica.

I do not see a corresponding process-descriptor removal/reconciliation path when the client replica is released.

This is related to the broader service/session ownership problem described in #2931: the service can own resources whose lifecycle is controlled mainly by a transient desktop-side protocol object.

For this issue, however, the suspend/resume trigger and the Windows path-vs-basename cleanup bug provide a much narrower reproduction path.

Effective failure sequence

A plausible sequence from the current source is:

XRay Connected
    ↓
Windows sleep/hibernate
    ↓
no PBT_APMSUSPEND cleanup
    ↓
existing tun2socks/TUN survive until resume
    ↓
PBT_APMRESUMESUSPEND
    ↓
wakeup()
    ↓
VpnConnection::reconnectToVpn()
    ↓
XrayProtocol::stop()
    ↓
deleteTun() → Windows no-op
tun2socks.kill()
    ↓
no wait-for-exit
    ↓
XrayProtocol::start()
    ↓
new IpcServerProcess::start()
    ↓
killProcessByName(absolute_path)
    ↓
does not match PROCESSENTRY32.szExeFile basename
    ↓
old process can still own TUN
    ↓
new tun2socks cannot cleanly acquire/start the same tunnel

This explains why manually killing the remaining process restores operation.

Suggested fix boundary

I would make XRay teardown transactional on Windows.

The required invariant should be:

stop() returned
    =>
old tun2socks process is confirmed dead
AND old TUN is no longer owned by it
AND a new generation may safely start

For example:

request process termination
→ wait for process exit with a bounded timeout
→ if necessary terminate the exact service-owned PID
→ verify/reconcile TUN state
→ release process descriptor
→ only then allow start()

The cleanup should use the exact process object/PID that Amnezia owns rather than killing arbitrary processes by executable name.

If killProcessByName() remains as a fallback, at minimum the Windows comparison must compare basename to basename rather than an absolute path to szExeFile.

Suspend/resume should also have an explicit lifecycle

It would be preferable to distinguish:

PBT_APMSUSPEND

from:

PBT_APMRESUMESUSPEND

and make the VPN session enter a known suspended/quiescing state before sleep.

On resume, a new VPN generation should not start until the previous generation is known to be stopped.

Useful regression tests

  1. Connect XRay on Windows → sleep → resume.

Expected after recovery:

exactly one tun2socks.exe
exactly one active tunnel
working DNS/routes
  1. Artificially delay old tun2socks termination during resume.

Expected:

new tun2socks is not started
until the previous process exits
  1. Leave a stale owned tun2socks process before start().

Expected:

it is reconciled by PID/process ownership
before a new process starts
  1. Unit-test the current Windows process-name cleanup with:
name passed:
C:\...\tun2socks.exe

PROCESSENTRY32.szExeFile:
tun2socks.exe
  1. Repeat sleep/resume 20+ times and verify there are no additional service process descriptors, tun2socks processes, stale adapters, routes, or DNS state.

  2. Repeat the same test with hibernation.

Version note

The directly relevant XrayProtocol, privileged-process start/kill code and Windows process cleanup are unchanged between the reported 5.0.1.5 code and current dev.

So I do not see evidence that this particular lifecycle defect has already been fixed.

I would classify the issue as source-confirmed, with the strongest concrete defect being the combination of:

resume-triggered stop/start
+
no Windows wait-for-tun2socks-exit
+
no-op Windows deleteTun()
+
broken absolute-path vs basename stale-process cleanup
<!-- gh-comment-id:5610157242 --> @HeisLuka commented on GitHub (Sep 9, 2026): I checked this against both `5.0.1.5` and current `dev`. The reported behavior is consistent with a source-level Windows lifecycle race, and there is also a concrete bug in the stale-`tun2socks` cleanup path. ### 1. Windows handles resume, but not pre-suspend teardown `client/platforms/windows/windowsnetworkwatcher.cpp` currently handles: ```cpp case WM_POWERBROADCAST: if (wParam == PBT_APMRESUMESUSPEND) { emit obj->wakeup(); } break; ``` There is no corresponding handling of `PBT_APMSUSPEND`. Therefore an active XRay/tun2socks data plane is not explicitly quiesced before Windows enters sleep/hibernation. After resume, `wakeup` is forwarded through the service and connected directly to: ```cpp VpnConnection::reconnectToVpn() ``` which performs: ```cpp setConnectionState(Vpn::ConnectionState::Reconnecting); m_vpnProtocol->stop(); m_vpnProtocol->start(); ``` So resume effectively means: ```text old session ↓ stop() ↓ immediately start a new session ``` The correctness of resume therefore depends on `stop()` providing a strict resource-release barrier. For XRay/tun2socks on Windows, it currently does not. ### 2. Windows `XrayProtocol::stop()` does not wait for tun2socks to exit The relevant code is in: ```text client/core/protocols/xrayProtocol.cpp ``` Non-Windows builds do: ```cpp m_tun2socksProcess->terminate(); auto waitForFinished = m_tun2socksProcess->waitForFinished(1000); if (...) { m_tun2socksProcess->kill(); } ``` but the Windows path is: ```cpp #else // terminate does not do anything useful on Windows // so just kill the process m_tun2socksProcess->kill(); #endif m_tun2socksProcess->close(); m_tun2socksProcess.reset(); ``` There is no `waitForFinished()` after `kill()` on Windows. Therefore `stop()` can return and `reconnectToVpn()` can call `start()` without a confirmed postcondition that the previous `tun2socks.exe` has exited and released its TUN resources. ### 3. `deleteTun()` does not provide that barrier on Windows either `XrayProtocol::stop()` calls: ```cpp iface->deleteTun(tunName); ``` before killing tun2socks. However `service/server/router.cpp` currently implements: ```cpp bool Router::deleteTun(const QString &dev) { #ifdef Q_OS_LINUX return RouterLinux::Instance().deleteTun(dev); #endif #ifdef Q_OS_MAC return RouterMac::Instance().deleteTun(dev); #endif return true; } ``` There is no Windows implementation. So on Windows `deleteTun()` simply reports success without releasing the TUN. The Windows TUN is effectively tied to the tun2socks process lifetime, which makes confirmed process termination even more important. ### 4. The fallback intended to kill an old tun2socks before starting a new one is broken on Windows This is the most concrete problem I found. Every privileged process start currently does: ```cpp void IpcServerProcess::start() { ... Utils::killProcessByName(m_process->program()); m_process->start(); ... } ``` For tun2socks, `m_process->program()` is set from: ```cpp Utils::tun2socksPath() ``` which returns: ```cpp Utils::executable("tun2socks", true); ``` On Windows, `Utils::executable(..., true)` returns an absolute path, conceptually: ```text C:\Program Files\AmneziaVPN\tun2socks.exe ``` But `Utils::killProcessByName()` enumerates processes using `PROCESSENTRY32W` and compares: ```cpp QString exeFile = QString::fromWCharArray(pe32.szExeFile); if (exeFile.compare(name, Qt::CaseInsensitive) == 0) { ... } ``` `pe32.szExeFile` contains the executable name: ```text tun2socks.exe ``` while `name` is the absolute program path: ```text C:\...\tun2socks.exe ``` Thus the comparison does not match. The intended startup safeguard: ```text kill stale tun2socks → start new tun2socks ``` effectively becomes: ```text search for process named "C:\...\tun2socks.exe" running process name is "tun2socks.exe" → no match → stale process is not terminated → start another tun2socks ``` This matches the report particularly well: manually terminating the old `tun2socks.exe` makes the client able to start again. ### 5. The service also retains privileged process descriptors `IpcServer::createPrivilegedProcess()` stores each privileged process in: ```cpp m_processes.insert(m_localpid, pd); ``` where the service-side `ProcessDescriptor` owns the real `IpcServerProcess`/`QProcess`. The desktop `m_tun2socksProcess` object is only a Qt Remote Objects replica. I do not see a corresponding process-descriptor removal/reconciliation path when the client replica is released. This is related to the broader service/session ownership problem described in #2931: the service can own resources whose lifecycle is controlled mainly by a transient desktop-side protocol object. For this issue, however, the suspend/resume trigger and the Windows path-vs-basename cleanup bug provide a much narrower reproduction path. ### Effective failure sequence A plausible sequence from the current source is: ```text XRay Connected ↓ Windows sleep/hibernate ↓ no PBT_APMSUSPEND cleanup ↓ existing tun2socks/TUN survive until resume ↓ PBT_APMRESUMESUSPEND ↓ wakeup() ↓ VpnConnection::reconnectToVpn() ↓ XrayProtocol::stop() ↓ deleteTun() → Windows no-op tun2socks.kill() ↓ no wait-for-exit ↓ XrayProtocol::start() ↓ new IpcServerProcess::start() ↓ killProcessByName(absolute_path) ↓ does not match PROCESSENTRY32.szExeFile basename ↓ old process can still own TUN ↓ new tun2socks cannot cleanly acquire/start the same tunnel ``` This explains why manually killing the remaining process restores operation. ### Suggested fix boundary I would make XRay teardown transactional on Windows. The required invariant should be: ```text stop() returned => old tun2socks process is confirmed dead AND old TUN is no longer owned by it AND a new generation may safely start ``` For example: ```text request process termination → wait for process exit with a bounded timeout → if necessary terminate the exact service-owned PID → verify/reconcile TUN state → release process descriptor → only then allow start() ``` The cleanup should use the exact process object/PID that Amnezia owns rather than killing arbitrary processes by executable name. If `killProcessByName()` remains as a fallback, at minimum the Windows comparison must compare basename to basename rather than an absolute path to `szExeFile`. ### Suspend/resume should also have an explicit lifecycle It would be preferable to distinguish: ```text PBT_APMSUSPEND ``` from: ```text PBT_APMRESUMESUSPEND ``` and make the VPN session enter a known suspended/quiescing state before sleep. On resume, a new VPN generation should not start until the previous generation is known to be stopped. ### Useful regression tests 1. Connect XRay on Windows → sleep → resume. Expected after recovery: ```text exactly one tun2socks.exe exactly one active tunnel working DNS/routes ``` 2. Artificially delay old tun2socks termination during resume. Expected: ```text new tun2socks is not started until the previous process exits ``` 3. Leave a stale owned tun2socks process before `start()`. Expected: ```text it is reconciled by PID/process ownership before a new process starts ``` 4. Unit-test the current Windows process-name cleanup with: ```text name passed: C:\...\tun2socks.exe PROCESSENTRY32.szExeFile: tun2socks.exe ``` 5. Repeat sleep/resume 20+ times and verify there are no additional service process descriptors, tun2socks processes, stale adapters, routes, or DNS state. 6. Repeat the same test with hibernation. ### Version note The directly relevant `XrayProtocol`, privileged-process start/kill code and Windows process cleanup are unchanged between the reported `5.0.1.5` code and current `dev`. So I do not see evidence that this particular lifecycle defect has already been fixed. I would classify the issue as source-confirmed, with the strongest concrete defect being the combination of: ```text resume-triggered stop/start + no Windows wait-for-tun2socks-exit + no-op Windows deleteTun() + broken absolute-path vs basename stale-process cleanup ```
Sign in to join this conversation.
No labels
pull-request
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
amnezia-vpn/amnezia-client#1423
No description provided.