Whilst I was trying to test a particular bug, I stumbled across this other one. With Sigmund’s help, I ran a number of tests, but after a while Sigmund insisted that I submit his bug report:
Bug: pygame.time.get_ticks() returns 0 every 256 calls (legacy backend, Windows)
Summary
In the legacy backend on Windows, pygame.time.get_ticks() periodically returns 0 instead of a sensible millisecond value. The interval is exactly 256 calls (count-based, not time-based — confirmed by varying the per-trial sleep). This breaks every higher-level OpenSesame API that depends on it, including:
openexp.Clock.time() (returns 0.0)
openexp.Mouse.get_click(timeout=N) (the timeout no longer fires, because the exit condition time - start_time >= timeout evaluates 0 - 0 = 0 forever; the call only returns if a real mouse event arrives)
A related symptom — Clock.time() returning 0.0 — was previously reported by user RemcoRenken (forum discussion 9858). I believe this is the same underlying bug.
Environment
- OpenSesame: [4.1.14]
- OS: Windows [11] [64-bit]
- Backend: legacy (pygame)
- pygame: [2.6.1]
- SDL: [2.28.4]
- Python: [3.13.5]
Minimal reproducer
Place this inline_script in the Run phase of a loop item with 1000 cycles. No Mouse, no Canvas, no Keyboard — just a timing probe:
import pygame
import time as pytime
import os
if not hasattr(exp, "_clk_log"):
log_path = os.path.join(exp.experiment_path, "clock_bug.log")
exp._clk_log = open(log_path, "w", buffering=1)
exp._clk_log.write(f"pygame: {pygame.version.ver} SDL: {pygame.version.SDL}\n")
exp._clk_log.write("trial,pg_ticks,clock_time,perf_counter_ms\n")
exp._clk_trial = 0
exp._clk_zeros = 0
exp._clk_trial += 1
trial = exp._clk_trial
pg_ticks = pygame.time.get_ticks()
clock_t = clock.time()
perf_ms = pytime.perf_counter() * 1000.0
exp._clk_log.write(f"{trial},{pg_ticks},{clock_t:.1f},{perf_ms:.1f}\n")
if pg_ticks == 0 or clock_t == 0:
exp._clk_zeros += 1
exp._clk_log.write(
f" *** ZERO at trial {trial}: pg_ticks={pg_ticks} clock_t={clock_t} "
f"(total zeros so far: {exp._clk_zeros})\n"
)
pytime.sleep(0.05) # 50 ms also reproduces — bug is count-based, not time-based
if trial % 50 == 0:
exp._clk_log.write(f"--- progress trial={trial} zeros={exp._clk_zeros} ---\n")
⚠️ Important: this reproducer must run inside a loop item. A standalone inline_script that iterates internally does not trigger the bug (pygame's timer subsystem is in a different initialization state in that context).
Observed behaviour
In the clean reproducer above, pg_ticks == 0 (and clock_t == 0) occurs deterministically at trials:
253, 509, 765, ... (interval = 256 calls exactly)
In my real VAS experiment, where each trial also calls Mouse.get_click() internally, the same bug occurs at:
252, 507, 762, ... (interval = 255 calls)
The fact that time.perf_counter() advances normally throughout proves this is a pygame/SDL2 timer-subsystem bug, not a system-clock or scheduling issue.
Why 255 vs 256?
The interval differs by 1 between the clean reproducer (256) and the real VAS task (255). The most likely explanation: Mouse.get_click() internally calls pygame.time.get_ticks() one extra time per trial (for its start_time), so a trial-counter-based interval of 256 underlying-ticks-calls maps to one fewer trial. 256 = 2^8 is therefore the canonical interval, strongly suggesting an 8-bit counter wraparound somewhere in SDL2's Windows timer code (or in how pygame interfaces with it).
Cascading failure in Mouse.get_click()
In openexp/_mouse/legacy.py::_get_mouse_event:
start_time = pygame.time.get_ticks()
while not event_found:
...
time = pygame.time.get_ticks()
if timeout is not None and time - start_time >= timeout:
break
When get_ticks() returns 0, both start_time and the loop's time become 0, so time - start_time = 0 < timeout forever. The timeout never fires. The loop exits only if a real mouse event arrives — so a participant who clicks can still continue the task, but the intended timeout=N no-response branch is unreachable until then. In an automated/unattended run (no user input), the experiment stalls indefinitely.
My experiment-level symptoms
-
At trial 253, my log shows:
=== TRIAL 253 start=0.0 ===
(where start was set from clock.time())
-
Even after I replaced clock.time() with time.perf_counter() * 1000 in my own vas_gui.py, the task still stalled at the same trial number — this time inside Mouse.get_click(), because that uses pygame.time.get_ticks() internally where I cannot reach it. The stall is resolved as soon as the participant clicks the VAS, but the intended timeout behaviour is lost, and in an automated run the task hangs.
-
Replacing Mouse.get_click() with direct pygame.event.get(MOUSEBUTTONDOWN) polling using time.perf_counter() for the timeout eliminated the stall completely.
Workaround (working)
In application code:
# Instead of: t = clock.time()
t = time.perf_counter() * 1000
# Instead of: button, pos, t = mouse.get_click(timeout=N)
start = time.perf_counter() * 1000
button = pos = t = None
while (time.perf_counter() * 1000) - start < N:
for event in pygame.event.get():
if event.type == pygame.MOUSEBUTTONDOWN:
button = event.button
pos = event.pos
t = time.perf_counter() * 1000
break
if button is not None:
break
After this, my experiment runs to completion without stalling.
Recommended fixes (for OpenSesame)
Ranked from minimal to robust:
-
Defensive guard in _get_mouse_event: if start_time == 0, refetch it on the next iteration. One-line fix, prevents the broken timeout.
-
Add a max-iteration safety counter in _get_mouse_event to break out after, say, timeout * 10 iterations regardless.
-
Replace pygame.time.get_ticks() with time.perf_counter() * 1000 throughout openexp/_clock/legacy.py and openexp/_mouse/legacy.py (and analogous keyboard code). This is the robust fix — time.perf_counter() is monotonic, high-resolution, and immune to whatever is breaking SDL2's timer on Windows.
Related issues
- Forum discussion 9858 (RemcoRenken):
clock.time() returning 0 with deterministic trial-number stalls — same underlying bug.
Why this matters
Any long-running experiment on Windows using the legacy backend is at risk of broken timeout behaviour after every 256 timer queries. For Mouse-based response collection this is roughly every 256 trials. The task can still recover when the participant produces a response, but:
- The intended
timeout=N "no response" branch is unreachable for that trial.
- An automated/unattended run (calibration sweeps, pilot scripts, EEG/fMRI auto-pauses, etc.) will hang.
- Any analysis or trigger logic that relies on
clock.time() will receive 0.0 for the affected trials, silently corrupting timing data.
Attached
clock_bug 3.log from the minimal reproducer above, showing zeros at trials 253, 509, 765 with time.perf_counter() advancing normally.
- Snippets from my real
vas_gui.py showing the same bug at trials 252, 507, 762, and the working workaround (vas_gui test 1000 x.csv).
Part of modified vas_gui.txt
clock_bug 3.log
vas_gui test 1000 x.csv
Whilst I was trying to test a particular bug, I stumbled across this other one. With Sigmund’s help, I ran a number of tests, but after a while Sigmund insisted that I submit his bug report:
Bug: pygame.time.get_ticks() returns 0 every 256 calls (legacy backend, Windows)
Summary
In the legacy backend on Windows,
pygame.time.get_ticks()periodically returns0instead of a sensible millisecond value. The interval is exactly 256 calls (count-based, not time-based — confirmed by varying the per-trial sleep). This breaks every higher-level OpenSesame API that depends on it, including:openexp.Clock.time()(returns0.0)openexp.Mouse.get_click(timeout=N)(the timeout no longer fires, because the exit conditiontime - start_time >= timeoutevaluates0 - 0 = 0forever; the call only returns if a real mouse event arrives)A related symptom —
Clock.time()returning0.0— was previously reported by user RemcoRenken (forum discussion 9858). I believe this is the same underlying bug.Environment
Minimal reproducer
Place this inline_script in the Run phase of a loop item with 1000 cycles. No Mouse, no Canvas, no Keyboard — just a timing probe:
Observed behaviour
In the clean reproducer above,
pg_ticks == 0(andclock_t == 0) occurs deterministically at trials:In my real VAS experiment, where each trial also calls
Mouse.get_click()internally, the same bug occurs at:The fact that
time.perf_counter()advances normally throughout proves this is a pygame/SDL2 timer-subsystem bug, not a system-clock or scheduling issue.Why 255 vs 256?
The interval differs by 1 between the clean reproducer (256) and the real VAS task (255). The most likely explanation:
Mouse.get_click()internally callspygame.time.get_ticks()one extra time per trial (for itsstart_time), so a trial-counter-based interval of 256 underlying-ticks-calls maps to one fewer trial. 256 = 2^8 is therefore the canonical interval, strongly suggesting an 8-bit counter wraparound somewhere in SDL2's Windows timer code (or in how pygame interfaces with it).Cascading failure in
Mouse.get_click()In
openexp/_mouse/legacy.py::_get_mouse_event:When
get_ticks()returns0, bothstart_timeand the loop'stimebecome0, sotime - start_time = 0 < timeoutforever. The timeout never fires. The loop exits only if a real mouse event arrives — so a participant who clicks can still continue the task, but the intendedtimeout=Nno-response branch is unreachable until then. In an automated/unattended run (no user input), the experiment stalls indefinitely.My experiment-level symptoms
At trial 253, my log shows:
(where
startwas set fromclock.time())Even after I replaced
clock.time()withtime.perf_counter() * 1000in my ownvas_gui.py, the task still stalled at the same trial number — this time insideMouse.get_click(), because that usespygame.time.get_ticks()internally where I cannot reach it. The stall is resolved as soon as the participant clicks the VAS, but the intended timeout behaviour is lost, and in an automated run the task hangs.Replacing
Mouse.get_click()with directpygame.event.get(MOUSEBUTTONDOWN)polling usingtime.perf_counter()for the timeout eliminated the stall completely.Workaround (working)
In application code:
After this, my experiment runs to completion without stalling.
Recommended fixes (for OpenSesame)
Ranked from minimal to robust:
Defensive guard in
_get_mouse_event: ifstart_time == 0, refetch it on the next iteration. One-line fix, prevents the broken timeout.Add a max-iteration safety counter in
_get_mouse_eventto break out after, say,timeout * 10iterations regardless.Replace
pygame.time.get_ticks()withtime.perf_counter() * 1000throughoutopenexp/_clock/legacy.pyandopenexp/_mouse/legacy.py(and analogous keyboard code). This is the robust fix —time.perf_counter()is monotonic, high-resolution, and immune to whatever is breaking SDL2's timer on Windows.Related issues
clock.time()returning0with deterministic trial-number stalls — same underlying bug.Why this matters
Any long-running experiment on Windows using the legacy backend is at risk of broken timeout behaviour after every 256 timer queries. For Mouse-based response collection this is roughly every 256 trials. The task can still recover when the participant produces a response, but:
timeout=N"no response" branch is unreachable for that trial.clock.time()will receive0.0for the affected trials, silently corrupting timing data.Attached
clock_bug 3.logfrom the minimal reproducer above, showing zeros at trials 253, 509, 765 withtime.perf_counter()advancing normally.vas_gui.pyshowing the same bug at trials 252, 507, 762, and the working workaround (vas_gui test 1000 x.csv).Part of modified vas_gui.txt
clock_bug 3.log
vas_gui test 1000 x.csv