sleep() won't let me sleep - how I fix accurate timer counter in my code

This forum is for general LUA-scripting, doesn't have to be specific to any applications or tools. Gain a better foundation for scripting by learning basic fundamentals and advanced topics in here


Post Reply
User avatar
bbfox
Table Master
Table Master
Journeyman Hacker
Journeyman Hacker
Posts: 398
Joined: Sat Jul 23, 2022 8:59 am
Answers: 0
x 847

sleep() won't let me sleep - how I fix accurate timer counter in my code

Post by bbfox »

TL;DR — In CE Lua, sleep(1) and sleep(10) cost exactly the same 15.47 ms. If your timeout loop counts iterations and calls it milliseconds, your "10 second" timeout is really ~ 155 seconds. Below is how I found it, how to measure it yourself, the numbers on my box, the CE-freeze half of the story, and what n to actually put in sleep(n) if you just want a poll loop and don't want to think about any of this.

All numbers here are measured, not estimated. Conditions: CE 7.7 (getCEVersion() = 7.70), Windows 11 26200, 100 iterations per row, pumped with processMessagesPaintOnly() every 10 iterations. Your mileage may vary but probably won't — see "Root Cause".


1. How I found it: the 10000 x sleep(1) loop

I have a Lua script that pokes a mailbox in an injected DLL and waits for the DLL to answer. Standard shape, everybody writes this:

Code: Select all

-- The ORIGINAL code. Looks fine. Is not fine.
local MAX_ITERS = 10000          -- "10 seconds", right? 10000 x 1 ms. Right??
local iters = 0

while readBytes(mailbox + STATUS_OFF, 1, true) ~= 1 do
  sleep(1)
  iters = iters + 1
  if iters >= MAX_ITERS then
    print('timeout after 10 s')  -- LOL no
    return
  end
end

Then one day the DLL did not answer, and instead of the 10-second timeout I designed, CE sat there like a brick for over two and a half minutes. Title bar went "Not Responding", window went white, the whole thing. I killed it twice before I got suspicious.

Repro is 3 lines. Paste into CE Lua Engine, Execute, then go make coffee:

Code: Select all

local t0 = getTickCount()
for i = 1, 10000 do sleep(1) end
print(getTickCount() - t0)      -- expect ~ 10000.  Actual: ~ 154700

~ 155 seconds for a loop I costed at 10. That is 15.5x. Not a rounding error, not "scheduler jitter", not my machine being busy. It is deterministic and it reproduces every time.

FYI, the same run also demonstrates symptom #2 — CE is frozen the whole time. Two separate bugs riding in the same loop. More on that in §5.


2. Root Cause

Two facts, and neither of them is Lua's fault:

  1. sleep is not standard Lua. Stock Lua has no sleep at all — verified on Lua 5.4.6, type(sleep) is nil, no luasocket, no FFI. CE registers its own (lua_sleep in LuaHandler.pas), which is a thin wrapper over FPC sleep() → Win32 sleep().

  2. Win32 sleep() is quantised to the system timer tick, which by default is ~ 15.625 ms (64 Hz). Any request smaller than one tick still costs one whole tick. You cannot buy a shorter nap than the OS sells.

So sleep(1) does not sleep 1 ms. It sleeps until the next tick. 10000 of those = 10000 x 15.47 = ~ 154.7 s. Mystery solved, and the culprit is Windows, as usual.

Two corollaries people get wrong:

  • sleep(0) measures 0.000 ms. sleep(0) yields the remaining timeslice — it does not pause. Don't use it as a "tiny sleep", it is a different API contract.
  • There is no "Sleep never returns early" guarantee. Microsoft's own synchapi.h Remarks:
    if the value is less than the clock resolution the thread may sleep less; if it is between one and two ticks the wait can land anywhere in between. So it is a band, not a constant. That bites in §4.

This also means the quantisation belongs to Win32, not to CE and not to Lua. Any language sitting on that API is on the same grid. C# Thread.Sleep is literally the same call — I used that fact later to settle an argument, see §4.


3. Detect it yourself + the numbers (sleep(n) x 1000)

Detection is easy once you know to look. Time a back-to-back loop and compare sleep(1) against sleep(10) — if they are the same, you have found the floor:

Code: Select all

local ITERS = 100
local function measure(n)
  local a = getTickCount()
  for i = 1, ITERS do sleep(n) end
  return (getTickCount() - a) / ITERS
end
print(string.format('sleep(1)=%.3f  sleep(10)=%.3f', measure(1), measure(10)))
-- => sleep(1)=15.470  sleep(10)=15.470     <-- same price. You get no discount.

Double check your ruler first, otherwise you are measuring the wrong thing:

Code: Select all

local t0, t1, spins = getTickCount(), nil, 0
t1 = t0
while t1 == t0 do t1 = getTickCount(); spins = spins + 1 end
print(string.format('timer tick: %.3f ms (after %d spins)', t1 - t0, spins))
-- => timer tick: 16.000 ms (observed after 164096 spins)

Note that: getTickCount() is itself tick-granular. It is good enough to catch a 15x error, and useless for anything finer. Remember this, it comes back in §4.

The table

Measured per call, then x1000 so you can see what a 1000-iteration loop actually costs. (x1000 column is the measured per-call value scaled, not a separate 1000-iteration run.)

sleep(n)measured / callx1000 = actualyou budgetedovershoot
sleep(0)0.000 ms0.0 s0 sN/A — yield, not a pause
sleep(1)15.470 ms15.5 s1 s15.5x
sleep(2)15.460 ms15.5 s2 s7.7x
sleep(5)15.470 ms15.5 s5 s3.1x
sleep(10)15.470 ms15.5 s10 s1.55x
sleep(15)19.690 ms19.7 s15 s1.31x — unstable, see below
sleep(16)30.160 ms30.2 s16 s1.89x
sleep(20)30.940 ms30.9 s20 s1.55x
sleep(30)31.560 ms31.6 s30 s1.05x
sleep(250)261.880 ms261.9 s250 s1.05x

Three things worth staring at:

  • The flat floor, sleep(1) through sleep(10), all 15.47. Asking for less does not get you less. If you wrote sleep(1) for "fast polling", congratulations, you have been polling at 64 Hz this whole time and paying full price.
  • sleep(15) -> sleep(16) jumps 10 ms for 1 ms more requested. 15.625 is the tick, so 15 sits right on the edge and jitters between one and two ticks (that is why its row reads 19.69 instead of a clean 15.47 — some iterations took two ticks). 16 is over the line and always pays two ticks.
  • sleep(250) = 261.88 ms. 250 is exactly 16 ticks, and it is the worst row in the table. This one cost me a code change: I had picked 250 as a pump-slice constant precisely because it was a whole number of ticks, on the theory that it would introduce no quantisation. The measurement says the reasoning was backwards — sitting exactly on a tick edge means any jitter at all costs a whole extra tick. Compare sleep(20) and sleep(30): both land mid-band, both within 0.2 ms of prediction. I changed the constant to 242. Landing mid-band is strictly better.

4. Warning: the table above is the special case

Everything above is a back-to-back loop — each sleep() wakes on a tick edge and immediately sleeps again, so the phase is pinned near 0 and you get clean tick multiples.

A real call site is not like that. Real code does work between the waits (an aobscan, a readBytes(), a "mr.Active = true"), and that work takes an arbitrary amount of time, which scrambles the phase. Then sleep(n) is spread over [n, n + one tick], mean near the middle:

nmeanobserved range[n, n+tick]back-to-backn + tick/2
513.111[5.101, 20.557][5, 20.625]15.62512.813
3038.114[30.125, 45.618][30, 45.625]31.25037.813
100108.374[100.152, 115.528][100, 115.625]109.375107.813

Total error: back-to-back model 10.38 ms, n + tick/2 model 1.16 ms. The give-away is the range, not the mean — the observed min/max land exactly on [n, n + one tick], and a uniform distribution over that interval has mean n + tick/2.

How I had to measure this, and why it matters to you: I could not settle it inside CE. CE's only clock is getTickCount, which IS the ~ 15.6 ms tick I was trying to resolve, and a busy-spin waiting on it can only ever exit on a tick edge — so the phase cannot be scrambled from inside CE at all. I "confirmed" the wrong model twice off a broken instrument before noticing. Since CE sleep() → Win32 Sleep() → same API as C# Thread.Sleep, I moved the probe out to a small C# program with QueryPerformanceCounter (100 ns) and 400 samples per row, and it separated the two models on the first run — 10x difference in error.

Lesson, free of charge: when your measuring instrument is coarser than the effect you are measuring, it will happily tell you whatever you want to hear. My model flipped three times before I changed rulers. Root Cause: instrument, not physics.

Sanity check that the two environments are on the same grid: the C# back-to-back numbers match the CE ones — 15.58 vs 15.47, 31.14 vs 30.94, 259.7 vs 261.9. Same tick, same behavior.


5. The other half: why CE freezes, and which paint function to use

Issue: CE's sleep is a bare sleep(). It pumps nothing. No paint, no messages, nothing. Your GUI thread is just gone for the duration. Windows greys the window out at roughly the 5-second mark and stamps "Not Responding" on it. CE is not crashed — it just isn't pumping.

You have two pumps, and they are not interchangeable:

pumpcost per calldispatchescan it re-enter your code?
processMessagesPaintOnly()~ 0.00 ms (below measurement)repaint onlyNo
processMessages()~ 20.30 msmouse + keyboard + timers, can run other LuaYes

Measured side by side, same machine, same session, pump every 10 iterations:

pumpfloor (sleep(1))sleep(20)sleep(30)
processMessagesPaintOnly15.47030.94032.190
processMessages (fallback)19.22034.38035.620

CE's own celua.txt calls processMessages() "not recommended". Now you have a measured reason on top of the documented one — it is not free, and the backlog grows with the time since the last pump, so the longer you go between pumps the more it costs when you finally call it. (Note: in a tight loop with no sleeping, the "no sleep" row reads 0.000 for both — no backlog to dispatch, nothing to pay for.)

The trade-off nobody mentions

With paint-only you cannot move the CE window. No dragging the title bar, no clicking buttons, no keyboard. It repaints, so it looks alive and Windows won't grey it out — but it is non-interactive for the whole wait. That is the deal:

  • processMessagesPaintOnly → CE looks alive, stays responsive-looking, cannot be moved or clicked, and cannot re-enter your handler. Free.
  • processMessages → CE is fully interactive, you can drag the window and click things — and the user can click the same button again and re-enter you mid-run. If you use this you need a re-entrancy latch, or your toggle ends up half-on-half-off. Costs 20 ms a call.

For a background wait, paint-only is the right default. For a long batch apply where the user might reasonably want to cancel, processMessages() + a busy latch is defensible. Please align on one and don't mix.

Detection: feature-test, NEVER version-gate

Code: Select all

-- CORRECT. An undefined global in Lua is nil, not an error, so old CE degrades quietly.
local _pump = (type(processMessagesPaintOnly) == 'function')
              and processMessagesPaintOnly
              or  processMessages

Code: Select all

-- WRONG. Do not do this.
local _pump = (getCEVersion() >= 7.7) and processMessagesPaintOnly or processMessages

Why: processMessagesPaintOnly is absent from the entire CE 7.5 public source tree, yet present and working in the 7.7 binary. So the introducing version is genuinely unknown (7.6?), and any threshold you pick is a guess. Worse — the public source lags the release, so "I grepped the source and it isn't there" does not prove "it isn't in the build you're running". I got burned by exactly that three times in one session. type(x) == 'function' tests the thing itself. Just test the thing itself.


6. The actual fix I did: stop counting iterations, use a real deadline

Iteration counting is the bug. The sleep duration is not a constant you control, so any loop that multiplies "iterations x assumed ms" is wrong by construction. Measure real elapsed time:

Code: Select all

local TIMEOUT_MS = 10000
local _pump = (type(processMessagesPaintOnly) == 'function') and processMessagesPaintOnly or processMessages
local _tick = (type(getTickCount) == 'function') and getTickCount or nil

local _deadline = _tick and (_tick() + TIMEOUT_MS) or nil
local _iters, MAX_ITERS = 0, 4000            -- fallback only, for a CE with no getTickCount
local timedOut = false

while readBytes(mailbox + STATUS_OFF, 1, true) ~= 1 do
  if _pump then _pump() end                  -- CE's sleep pumps nothing. This line is not optional.
  sleep(10)                                  -- see §7 for why 10 and not 1
  _iters = _iters + 1
  if (_deadline and _tick() >= _deadline) or (not _deadline and _iters >= MAX_ITERS) then
    timedOut = true
    break
  end
end

if timedOut then
  print('mailbox timeout - DLL did not respond')
  return                                     -- a timeout IS an error. Do not fall through.
end

Notes on that shape, all of them learned the hard way:

  • getTickCount() deadline, iteration count only as a fallback. getTickCount exists in every CE that matters, but the fallback errs long (~ 22 s instead of 10 s if the fallback pump is processMessages()) rather than short, which is the safe direction.* Pump before the sleep, i.e. as the first statement of the loop body, so the repaint happens before you go dark rather than after.
  • A timeout is an error path. return, don't break into your success handling. And if your script auto-closes the Lua Engine window on success, make that close unreachable from every error path — otherwise the window that would have shown the user what went wrong closes itself. Ask me how I know.
  • If your protocol distinguishes failure modes, read them instead of guessing. In my case status 0 = the DLL never picked the command up (usually a stale mailbox address) and 0xFF = it took the command and wedged. Two completely different bugs, one useless "timeout" message.

7. "I don't care, just tell me what number to put in sleep()"

Fair. Here is the cheat sheet.

Default answer: sleep(10). (easy to remember)

Rationale, since the number looks arbitrary:

  • Anything from sleep(1) to sleep(14) costs the same one tick (~ 15.5 ms). sleep(1) is not faster than sleep(10), it is only more dishonest. Write the number you are actually paying.
  • 10 keeps 5 ms of margin below the 15.625 tick edge, so it will not randomly jitter up to two ticks the way sleep(15) does.
  • 15.5 ms per poll = ~ 64 Hz. For reference, my DLL mailbox round-trips complete in 0–55 ms, so a 15.5 ms polling granularity adds at most one tick of latency. Anything finer is imaginary anyway.

If you want to poll gentler (long waits, don't want to spin the pump 64 times a second), pick a mid-band value, not a tick multiple:

wantuseactualwhy
~ 64 Hzsleep(10)~ 15.5 ms1 tick, mid-band
~ 32 Hzsleep(24)~ 31.3 ms2 ticks, mid-band
~ 21 Hzsleep(40)~ 46.9 ms3 ticks, mid-band
chunking a long waitsleep(242)~ 250 msmid-band; not 250, which measures 262

Avoid these — they sit on or next to a tick boundary and will jitter a whole extra tick: 15, 16, 31, 32, 46, 47, 62, 63, 250. The tick multiples are 15.625, 31.25, 46.875, 62.5, 78.125, 93.75, 109.375, 125 ... anything within a millisecond or two of those is a bad pick.

And regardless of which n you choose:

  1. Never count iterations as a time budget. getTickCount() deadline, always.
  2. Always pump inside the loop, or CE goes white and the user kills it.
  3. Feature-test the pump, don't version-gate it.

Or, use formula: n_k = round(15.625 × k − 7.8125) k = 1, 2, 3 ...

ksleep(n)acturalpooling freqcondition
1815.6 ms64 Hzmemory pooling for response like shared mem
22331.3 ms32 Hzgeneral pooling
33946.9 ms21 Hzi.e. waiting for DLL response
45562.5 ms16 Hzbackground jobs
57078.1 ms12.8 Hz
68693.8 ms10.7 Hz
7102109.4 ms9.1 Hz
8117125.0 ms8 Hzlong wait, UI heartbeat
9133140.6 ms7.1 Hz
10148156.3 ms6.4 Hzslow pooling

or calc yourself. for example round(15.625 * 16 − 7.8125) = 242


8. Summary

Issue:       "10 second" Lua timeout actually took ~ 155 s, and froze CE while doing it. Root Cause: CE sleep() -> FPC Sleep() -> Win32 Sleep(), 
             quantised to the ~ 15.625 ms system tick. Every sub-tick request costs one whole tick. Also, sleep() pumps no messages at all.
Action Item: (1) replace iteration counting with a getTickCount() deadline
             (2) pump every iteration, prefer processMessagesPaintOnly, feature-tested
             (3) use sleep(10) as the poll step; avoid exact tick multiples
             (4) treat timeout as an error path, not a fall-through
Status:      Fixed. Every emitted script in my project now uses the same shape.
ETA:         N/A, already shipped.

Table is free to use, but need to leave the author's name and source URL: https://opencheattables.com.
Table will not be up-to-date. Feel free to modify it, but leave credit to the source.
Tip me a coffee? https://ko-fi.com/bbfoxmodding


Post Reply