The Screen That Lied: Building Idle Timers That Actually Tick
September 4, 2026
Here’s a bug report you can’t reproduce with a test: “the sessions view shows a list of users logged in, and when they change state Connected → disconnected the display updates immediately — but the idle time doesn’t update. If I quit the window and reopen it, the idle times are current again.”
The sysop was right, and the cause was architectural. Every screen in Phosphor BBS is drawn by our own terminal UI toolkit, jterm — a pure-Java, lanterna-style TUI framework with double-buffered, diff-based refresh. That diff-based refresh is the crown jewel: when you redraw, you only pay for the cells that changed. But it had a blind spot.
The event loop only wakes for input
jterm’s GUI runs a classic event loop:
public void runEventLoop() throws IOException {
needsRefresh = true;
while (running) {
boolean hadInput = processInput();
updateScreen(); // repaints ONLY if needsRefresh was set
if (!hadInput) Thread.yield();
}
}
updateScreen() early-returns unless something flagged needsRefresh — a keystroke, a connection event, a chat message. Every event source in the BBS called requestRefresh() when its data changed, so event-driven data was always fresh.
The idle column wasn’t event-driven. It’s time-driven: idle = now − lastKeystroke, recomputed live from the session registry on every render. The data was always correct. Nothing ever asked for a re-render to show it.
So the display told the truth exactly twice per user session: at connect, and at disconnect. In between, a user sitting idle for an hour would show “0s” for an hour — until someone else connected, a message arrived, or the sysop pressed a key.
The two wrong fixes
The obvious fix is a per-screen timer: a virtual thread in the sessions view that sleeps 30 seconds, calls requestRefresh(), repeats. Every screen with time-derived data grows one. They need lifecycle management (stop on close, don’t leak on crash), they need tuning per screen, and they multiply.
The other obvious fix is a global repaint thread that invalidates the whole UI every N seconds whether anything changed or not. That burns CPU on the server — one session is cheap, but a BBS’s whole point is that dozens of them run simultaneously, and most screens are mostly static.
The right fix is smaller than either.
One method, one check
Windows now declare how often they want to be repainted:
// jterm — the framework
public interface Window {
default long autoRefreshIntervalMillis() { return 0L; } // off by default
}
And the event loop — which is already spinning every few milliseconds waiting on a 5ms input poll — checks whether any window’s interval has elapsed:
while (running) {
boolean hadInput = processInput();
checkAutoRefresh(); // nanoTime compare; sets needsRefresh when a tick is due
updateScreen();
if (!hadInput) Thread.yield();
}
That’s the entire mechanism. No timers, no threads, no scheduler, no lifecycle. The loop was already burning the cycles to make this free; the only new work per idle spin is a System.nanoTime() and, when a tick is due, the repaint goes through the normal diff-based refresh — so a tick that changes four idle-digit cells pays for four cells and nothing else.
The cost asymmetry is the whole trick. Diff-based refresh means “repaint every 30 seconds” costs almost nothing when the frame hasn’t changed, and a few cells when it has. A framework that repainted whole frames would have made this feature expensive. Ours makes it free.
The BBS layer: one default, four overrides
Down in the BBS, every screen inherits the framework default of off — because a toolkit shouldn’t assume anything. Then one abstract class flips the default for the whole application:
// ContentScreen — the base class of every BBS screen
private static final long DEFAULT_AUTO_REFRESH_MILLIS = 60_000L;
@Override
public long autoRefreshIntervalMillis() {
return DEFAULT_AUTO_REFRESH_MILLIS;
}
Every BBS screen now refreshes once a minute. Screens that watch live data override with a tighter cadence:
// SessionLogScreen, Who's Online, SysOp Panel, System Stats
@Override
public long autoRefreshIntervalMillis() {
return 30_000L;
}
Five lines of application code, total. The sysop watches the sessions list and the idle columns tick over on their own — 15s becomes 45s becomes 1m 15s — without touching the keyboard, while someone idling in chat keeps their stale-until-keypress behavior exactly where it belongs: nowhere.
The test that made the bug reproducible
The regression test for this is subtler than it looks, because the naive version passes a broken implementation and fails a correct one.
If your test hand-rolls the loop — processInput(); updateScreen(); in a while — it never executes the new checkAutoRefresh() path, because that path lives inside the real runEventLoop(). You’d be testing your own test harness. The test has to run the production loop on a thread and observe the window getting repainted with zero input:
loop = new TestLoop(gui); // runs gui.runEventLoop() for real
loop.start();
long deadline = System.nanoTime() + 3_000_000_000L;
while (w.draws.get() <= afterInitial && System.nanoTime() < deadline) {
Thread.sleep(10);
}
assertTrue(w.draws.get() > afterInitial,
"a window with an interval must be repainted while idle");
A counting window records every draw() call; the test asserts the count grows while the loop spins untouched. Zero input, real loop, real dispatch. This is the same lesson from the last post — tests must exercise the path production takes, not a path that’s merely shaped like it — but this time we wrote it down as a rule: if the framework has a dispatch loop, your test runs that loop.
What it cost
One default method on an interface. One private method and one long field in the event loop. One constant and one override on an abstract class, plus four tighter overrides. Two new test classes. Everything else — the five-line total in the application layer, the zero new dependencies, the zero background threads — is the point.
The sessions view used to tell the truth twice per session. Now it tells the truth thirty times a minute, and nobody had to write a timer to make it happen.
Try it live: the sessions view ticks by itself. Web terminal: http://bbs.phosphorbbs.net:8088/terminal — or ssh phosphorbbs.net -p 2222, or telnet phosphorbbs.net 2323. Log in as a sysop-level account and open the session log; then don’t touch anything, and watch the idle times move.
#digitalthirdplace #dtp #socialterminal #communityserver #selfhosted #java #tui