Bug Alert: Accidental Double-Clicking of Long-Running Processes
In the spring of 2021, an ear-splitting screeching filled the otherwise quiet night air in and around the District of Columbia.
Perhaps surprisingly, the irritating noise had nothing to do with the acrimony of American politics. Rather, it was the sound of billions of periodical cicadas–a group known as Brood X–emerging from 17 years of underground dormancy.
These bugs are ever-present just below the surface of the earth. We humans walk above them, blissfully unaware of their existence.
Until one day–like some hallucinating entomologist's fever dream–they emerge in unison to spread chaos: the aforementioned 96-decibel mating calls; swarms so thick they cause car crashes; and, of course, the cacophony of calls to abandon beef in favor of bugs as a climate-friendly protein alternative (knock yourselves out, folks, that's just more rib eye for me).
The life cycle of these cicadas is a curiosity of biology.
It's also an apt metaphor for today's article.
Logic Errors: The Most Insidious of All Bugs
A logic error is a piece of code that executes properly but does not behave the way you intended.
Logic errors are the worst kind of errors because they can go unnoticed for so long.
And among logic errors, the most infuriating to identify, debug, and fix are concurrency errors.
You may be thinking we don't have to worry about concurrency errors as Access developers. After all, Access is single-threaded. But, Access is also event-driven. And, if we're not careful, the event-driven nature of Access still allows us to unwittingly introduce race conditions into our code.
The Accidental Double-Click of a Long-Running Process
Every few years, this bug pattern comes around to bite me.
Here's what the pattern looks like:
- User clicks a button that starts a long-running process
- Before the process can finish, the user clicks the button again, causing trouble
The nature of the second click takes a few different forms:
- A user who routinely double-clicks everything
- A finger twitch that leads to an accidental double-click
- An impatient user who clicks the button a second time because the process is taking too long (this is especially common if you don't provide a progress meter or at least switch the mouse icon to an hourglass)
The consequences of this type of bug can range from annoying to catastrophic. One common situation involves looping through recordsets and modifying database records. Depending on the specifics, you can end up inserting duplicate records; generating a primary key violation that breaks a process halfway through; or deleting records that should not be deleted.
Necessary Conditions to Reproduce the Bug
As it turns out, I actually had some trouble creating sample code to demonstrate this bug pattern. There are a few criteria that appear to be necessary to create the conditions for this bug:
- A call to DoEvents (this allows Access to process the second click; without DoEvents, the single-threaded nature of VBA generally prevents this bug from appearing; however, keep in mind that DoEvents is often used to allow for the visual updating of a progress bar–which is more likely to be used with a long-running process)
- A slow enough process that the user has time to double-click the button (I simulate this with the Sleep API in my example below)
- A non-idempotent process (idempotent functions can safely be run multiple times)
It's a bit of an academic exercise, but I'm actually a bit torn on whether a process could be considered idempotent if it was subject to this kind of bug. For example, I can easily imagine a procedure that could be run to completion as many times as you like, but that causes problems if it is interrupted partway through. Would that be considered idempotent?
Minimal Complete and Verifiable Example
With the help of ChatGPT, I came up with a minimal example to demonstrate the nature of this bug:
-
Create a form with three buttons:
- btnNoGuardClause: with caption "No Guard Clause"
- btnWithGuardClause: with caption "With Guard Clause"
- btnResetCounters: with caption "Reset Counters"
-
Add two text boxes:
- tbClickCounter: with label captioned "Click counter:"
- tbProcessCounter: with label captioned "Process counter:"
-
Copy the code below into the form's code behind module:
Private Declare PtrSafe Sub Sleep Lib "kernel32" (ByVal dwMilliseconds As Long)
Private Sub btnResetCounters_Click()
Me.tbClickCounter = 0
Me.tbProcessCounter = 0
End Sub
Private Sub btnNoGuardClause_Click()
IncrementCounters
End Sub
Private Sub btnWithGuardClause_Click()
Me.btnWithGuardClause.Enabled = False
IncrementCounters
Me.btnWithGuardClause.Enabled = True
End Sub
Private Sub IncrementCounters()
' Increment ClickCounter with each click
Me.tbClickCounter = Me.tbClickCounter + 1
' Simulate a time-consuming process
Dim CurrentValue As Integer
CurrentValue = Me.tbProcessCounter
Dim i As Long
For i = 1 To 5
Sleep 200
DoEvents 'The DoEvents is what allows Access to process subsequent button clicks
Next i
' Increment the ProcessCounter based on the previously read value
Me.tbProcessCounter = CurrentValue + 1
End Sub
- Switch the form to Form View
- Click the [Reset Counters] button to set the two text boxes to 0
- Click the [No Guard Clause] button rapidly
- The "Click counter" text box will be larger than the "Process counter" text box
- Click the [Reset Counters] button to set the two text boxes to 0
- Click the [With Guard Clause] button rapidly
- The "Click counter" text box will increase in unison with the "Process counter" text box
While this example is obviously contrived, it demonstrates the potential for logic errors if you don't consider what might happen if your users double-click the command button for a long-running process.