Note: I cannot guarantee that any of the links to Microsoft documents which appear in the following article will continue to work, only that they worked when this article was written. Microsoft's documentation pages appear to move around entirely at the whims of madmen, and a link that works today may result in a 404 error tomorrow. Now you know what your favorite search engine is for.
The setup: why are these machines so slooooow?
I was recently asked to determine why a group of machines at work were running slowly. The machines in question exist in a shared environment and are used by multiple individuals throughout the course of a given day. There were a few things causing them to malfunction, but one of the more interesting issues I found was that the users were not logging off when they were finished with the machines; instead, they would walk away and the next person would simply hit "Switch User" to log into them. Therefore multiple disconnected user sessions remained in memory, many with programs still running -- and collectively, these abandoned sessions served as an anchor which weighed down the performance of the machines.
So no one logs off. Can you make them?
A Google search indicates this is a common problem because Windows does not provide a way to automatically terminate sessions that were started at the machine itself; such a mechanism is provided only for Remote Desktop sessions.
Nevertheless, there are a few different ways to solve the problem -- but these tend to involve editing a GPO or other mechanism in order to execute a script or program within the context of each session that is started on a machine. This script or program is then responsible for polling the session and determining if it has become idle for long enough to warrant bringing it to an end.
For my purposes, writing a GPO was overkill: there were only a few machines that would require it. I didn't want to execute a script in the context of each session simply because RAM was already at a premium on these machines, given the number of background services and programs they already have to run: the antivirus suite alone involves nearly a dozen (and is also a contributor to the performance hit each machine endures, albeit a necessary one).
Instead, as with the majority of my PC problems, I turned to writing a custom program to do the job. The Abandoned Sessions Eliminator (ASE for short) was born.
The Abandoned Sessions Eliminator
If you'd like to know how it works, then keep reading! If you're only interested in the source code, you can find it in a ZIP file at the bottom of the page.
Remotely sitting at the machine
You can query your favorite search engine for "Windows desktop sessions" if you want to find more details about how Windows handles user sessions. I won't get into the specifics here. For our purposes it's enough to acknowledge that multiple abandoned user sessions on a shared machine create problems. Fortunately, the way that Microsoft chose to implement multiple user sessions on a single desktop machine also provides us with a solution.
Since the days of Windows XP, Windows has implemented fast user switching as the means in which it manages multiple user sessions on a single machine. This implementation was further refined with Windows Vista and boils down to the pretense that the desktop machine is a Windows Terminal Server, and that each logged-in user has obtained access to the machine by way of Remote Desktop. While I pointed out above that there is a mechanism to terminate idle Remote Desktop sessions after a certain interval, this mechanism only works for sessions that actually came from a remote machine. However, because of Fast User Switching there is a way to use Remote Desktop Services to manage local user sessions -- at least for machines running Vista or newer.
The Remote Desktop Services API is contained within wtsapi32.dll
; the corresponding functions and structures can be found in wtsapi32.h
. There are three functions that are of greatest interest to us in our quest to eliminate abandoned local sessions; two are involved with obtaining information about the existing sessions on a given machine:
as well as the one that logs off a session:
The judicious use of these functions will allow us to determine how long each session on a given machine has been idle -- that is, how long it's been since a user interacted with it -- and to forcibly log off those sessions which have exceeded our desired time frame.
Step 1: Enumerate idle sessions
The first step is to obtain a list of sessions, which we can then examine for idle sessions. This takes place inside of AnASEApplication.collectIdleSessions()
:
if WTSEnumerateSessionsEx(WTS_CURRENT_SERVER_HANDLE, @queryLevel, 0,
@desktopSession, @desktopSessionCount) = false then
// Failed to enumerate sessions
exit;
// We skip session 0 because it's a special session used by system services
i := 1;
// Loop through each session
while i < desktopSessionCount do
begin
// We only care about disconnected sessions
if desktopSession[i].State = WTSDisconnected then
begin
sessionInfo := nil;
// Query for additional information about the session
if WTSQuerySessionInformation(WTS_CURRENT_SERVER_HANDLE,
desktopSession[i].SessionId, WTSSessionInfo, @sessionInfo,
@queryLevel) then
begin
if Self.getIdleTimeSecondsFrom(sessionInfo) >= mySessionIdleMaximum then
MyIdleSessions.push(sessionInfo)
else
WTSFreeMemory(sessionInfo);
end;
end;
inc(i);
end;
WTSFreeMemoryEx(WTSTypeSessionInfoLevel1, desktopSession,
desktopSessionCount);
After setting up the required queryLevel
and our other variables, we immediately call WTSEnumerateSessionsEx()
. WTS_CURRENT_SERVER_HANDLE
, passed as the first parameter, informs the function that we want the sessions from the machine running our code.
Before it returns successfully, WTSEnumerateSessionsEx()
allocates and returns an array of structures that represent all running sessions on the machine; desktopSession
gets pointed at this array, while desktopSessionCount
is filled in with the number of elements in the array. Note that this array includes session 0, the special session used by services and other processes that don't actually require user interaction. Forcibly logging off session 0 will bring about the apocalypse -- at least on a local scale -- so we can exclude it.
We loop through the remaining elements of the array, checking for disconnected sessions (see below) and then using WTSQuerySessionInformation()
to obtain the data we need to calculate how long the sessions have been idle. Any sessions which have been idle for longer than the value specified by mySessionIdleMaximum
are added to the MyIdleSessions
queue for further processing.
Finally, it's important to free the memory allocated by WTSEnumerateSessionsEx()
, by calling WTSFreeMemoryEx()
. There are enough programs out there that introduce memory leaks without us doing so ourselves.
Which sessions are idle?
On first glance, it seems like defining this will be easy. The WTSINFO
structure returned by WTSQuerySessionInformation()
includes a field named LastInputTime
, which Microsoft documents as "the time of the last user input in the session". If the user inputs something into the session -- whether by mouse, keyboard, touch screen, voice, camera, rude gestures, or direct neural interaction -- then they must be using it, right? So all we have to do is check this field to know when the user last input something, and if it's been too long we can assume they've stepped away from the machine and boot the session -- right?
Unfortunately, no. After testing this idea it became apparent that this field is updated exactly once: when the user first logs into the session. It is never updated again, regardless of how the user interacts with the session. The LastInputTime
field therefore cannot be used to determine whether a session has been idle. We need something else. Because of Fast User Switching, we have something else: the DisconnectTime
field.
When a user chooses "Switch User" and logs into a Windows machine, the local window station is assigned to the new user session. Since there can only be one active local window station connected to a user session, this means it must be disconnected (see where this is going?) from any previous user sessions. Thus we can use the timestamp recorded in the DisconnectTime
field as a basis for determining whether a session is idle. After all, if it's disconnected from the main display and the machine is not really a Terminal Services server, then odds are high that the session is not actively being used.
This is why we check whether the State
field of each session structure returned by WTSEnumerateSession()
is WTSDisconnected
.
Step 2: Determine how long a session has been idle
This is handled by AnASEApplication.getIdleTimeSecondsFrom()
, and is fairly straightforward:
{ Windows file times are stored as 100-nanosecond intervals. We need to
convert these intervals into seconds.
}
WINDOWS_FILETIME_TO_SECONDS = 10000000;
begin
result := 0;
if (thisSession = nil) or (thisSession^.State <> WTSDisconnected) then
exit;
// Determine how long the session has been disconnected
result :=
(thisSession^.CurrentTime.QuadPart - thisSession^.DisconnectTime.QuadPart) div
WINDOWS_FILETIME_TO_SECONDS;
end;
Though not explicitly documented, the various timestamp fields in WTSINFO
are essentially FILETIMEs: 64-bit values representing the number of 100-nanosecond intervals since January 1, 1601 UTC.
To determine how long the session has been idle, we could use GetSystemTime()
to get the current time, and then send the resulting value to SystemTimeToFileTime()
so that it can be compared against the DisconnectTime
value. However, there's an easier way: the CurrentTime
field. This field is filled by WTSQuerySessionInformation()
at the time it is called, and is close enough to "now" for us to use in determining how much time has passed since the session was disconnected -- and, therefore, how long it's been idle.
Step 3: Eliminate idle sessions
This is the job of AnASEApplication.ejectIdleSessions()
:
if result > 0 then
begin
idleSessionUserNames := '';
thisIdleSession := PWTSINFO(MyIdleSessions.pop);
while thisIdleSession <> nil do
begin
// Collect the name of the session user
idleSessionUserNames := idleSessionUserNames +
SysUtils.format(aseFmtIdleSessionUserName, [
thisIdleSession^.SessionID,
pchar(@thisIdleSession^.Domain[0]),
pchar(@thisIdleSession^.UserName[0]),
Self.getIdleTimeSecondsFrom(thisIdleSession)
]);
// Log off the idle session
WTSLogoffSession(WTS_CURRENT_SERVER_HANDLE, thisIdleSession^.SessionId,
true);
WTSFreeMemory(thisIdleSession);
thisIdleSession := PWTSINFO(MyIdleSessions.pop);
end;
Self.logNoteFmt(aseNoteEjectIdleSessions, [
mySessionIdleMaximum, idleSessionUserNames
]);
end
else if result = 0 then
Self.logNoteFmt(aseNoteNoIdleSessions, [mySessionIdleMaximum])
else
Self.logErrorMessage(aseErrorCollectIdleSessions);
This function is essentially the engine behind ASE; it drives everything else. Here we call AnASEApplication.collectIdleSessions()
to obtain a list of disconnected sessions that have exceeded the idle threshold.
As long as AnASEApplication.collectIdleSessions()
returns a value greater than 0, we can process the queue represented by MyIdleSessions
. A session (represented by a WTSINFO
structure) is popped from the queue. The domain username of the user to whom the session belonged is noted before WTSLogoffSession()
is called to log off the session. Since the WTSINFO
structure which represents the session was originally allocated by the Remote Desktop API [inside of AnASEApplication.collectIdleSessions()
], we use WTSFreeMemory()
to release the WTSINFO
structure which represented the session before moving onto the next.
When all idle sessions have been processed, a note is entered into the Application Event Log. The note indicates which sessions were forcibly logged off, in case there is ever a need to know.
If AnASEApplication.collectIdleSessions()
returns a value of 0, it means there are no sessions that have exceeded the idle threshold. In this case, a note recording that fact is entered into the Application Event Log.
Finally, if AnASEApplication.collectIdleSessions()
returns a value that is less than 0, it means an error has occurred. This fact is duly noted in the Application Event Log.
Using the program
You can see from the above that, each time the ASE runs, it collects a list of idle and disconnected sessions; from these, it determines which disconnected sessions have exceeded the defined maximum and logs off those sessions. Naturally, the program can only do this if it's running as an elevated process. Moreover, you can see that the program is not designed to remain in memory once it has done its job, since memory was at a premium on the affected machines. Thus, in order to be an effective tool, there must be a way to run the program at intervals, and provide it with elevated privileges.
There is such a way: the Task Scheduler.
With Task Scheduler, you can set up a task that runs in the SYSTEM context at regular intervals. The task executes the Abandoned Sessions Eliminator, which checks for and logs off any disconnected user sessions that have exceeded the specified duration. For my purposes, the task is set up to run whenever any user first logs into the machine after it's rebooted, and then for every thirty minutes thereafter. Your needs may vary, but I've provided XML template for Task Scheduler in the source archive below.
Configuring the program
ASE is designed to be configured indirectly, through an INI file, or directly through the command line. If an INI file is used, then its settings override anything passed on the command line.
The below table lists the available options, all of which are optional:
INI file option | Command-line option | Description |
---|---|---|
boolLogToSysLog | -ls or --syslog | Whether or not to log program activity to the system application log. Defaults to "y" if not specified. |
strLogFilePath | -lf or --log | The path and file name of the file to use for logging. Ignored if the above logging option is specified. |
intEjectIdleAfterSeconds | -t or --timeout | The number of seconds that a user session may be disconnected before it is considered abandoned. Defaults to 3600 (1 hour) if not specified. |
N/A | -cf or --config | Specifies the path and filename of an INI file to use for configuration of ASE. By default, ASE looks for a file named "ase.ini" in the same directory as the ASE executable. |
N/A | -? or --help | Print program usage information on the command line. |
Obtaining ASE
This ZIP archive contains the source code for ASE, as well as a version compiled for 32-bit Windows. The 32-bit version runs just as well on 64-bit Windows machines, but if you'd like to compile the program yourself, you'll need Lazarus.