Code:

Eliminating abandoned Windows login sessions

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():

  // Enumerate desktop sessions
  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:

const
  { 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():

  result := Self.collectIdleSessions;

  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 optionCommand-line optionDescription
boolLogToSysLog-ls or --syslogWhether or not to log program activity to the system application log. Defaults to "y" if not specified.
strLogFilePath-lf or --logThe path and file name of the file to use for logging. Ignored if the above logging option is specified.
intEjectIdleAfterSeconds-t or --timeoutThe 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 --configSpecifies 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 --helpPrint 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.