Skip to content

Turn the display off reliably when the program exits or the Windows session ends - #1082

Open
boscocp wants to merge 2 commits into
mathoudebine:mainfrom
boscocp:fix/flush-display-queue-before-exit
Open

boscocp wants to merge 2 commits into
mathoudebine:mainfrom
boscocp:fix/flush-display-queue-before-exit

Conversation

@boscocp

@boscocp boscocp commented Sep 6, 2026

Copy link
Copy Markdown

Two independent reasons the panel can stay lit after the program is done with it. The
first commit is about the screen-off command being cut on the way out; the second is
about the program never being told to leave in the first place, on Windows.


1. The screen-off command was cut on the way out

Problem

Exiting the system monitor could cut the screen-off command mid-transfer, or let a sensor
frame reach the panel after it. Two separate causes:

  1. is_queue_empty() returned update_queue.empty(), which becomes true the moment
    QueueHandler takes a request out of the queue — not when the write to the serial port
    completes. So wait_for_empty_queue() in clean_stop() returned early and os._exit(0)
    cut the screen-off command mid-transfer.
  2. periodic() ran action() one last time after STOPPING was set (the if not STOPPING
    only skipped re-scheduling), so a sensor thread could queue a frame behind the turn-off
    requests clean_stop() had just queued.

Changes

  • is_queue_empty() uses the queue's count of unfinished tasks, which only drops to zero
    after task_done() — i.e. once the request has actually been processed.
  • periodic() no longer runs the action while the program is stopping.
  • QueueHandler becomes its own loop with get(timeout=). The scheduled job blocked in an
    untimed get() and never noticed STOPPING. An exception raised by a request is now logged
    instead of killing the queue's only consumer.
  • clean_stop() waits for the queue handler thread before closing the port: a late write
    would fail on the closed port, and WriteLine() reopens the port when that happens.
  • New Display.close(), since os._exit(0) skips the driver destructors.
  • LcdSimulated no longer raises AttributeError on close when its web server did not start
    (port already in use) — a path only reached now that close actually runs.

Measurements

before after
the wait returns while the last queued command is still being written yes no
extra frames queued after STOPPING (real scheduled job) 1 0

How wide the truncation window is depends on what is last in the queue. On an idle exit on
rev A only SCREEN_OFF is queued — SetBackplateLedColor is a no-op there (lcd_comm.py:234,
overridden only by rev B and the simulated display) — so the window is very small; it is much
wider whenever the queue still holds image data.

Dropping @schedule(timedelta(milliseconds=1)) also removes a real cost. sched.enter(0.001)
slept ~1 ms per queued chunk by construction, and a full-screen 320×480 RGB565 image is
120 chunks (lcd_comm_rev_a.py:218, chunked(rgb565le, width * 8)). time.sleep(0.001)
measures 1262 µs on macOS and 1505 µs on Windows, so that is 151 ms and 181 ms of pure
sleep per full-screen image. On a Turing 3.5" rev A the startup image flushes in about 2.5 s,
so the sleep alone was roughly 7% of the transfer time.

On hardware

Tested on a Turing 3.5" (rev A) on Windows, 5 runs per branch, exiting from the tray icon while
the startup image was still being flushed.

On main, the run that exited with the most data still queued hit the full 5 s wait budget. The
sensor jobs started after STOPPING had been set and queued a frame each behind the turn-off
request, so the queue never drained and the exit truncated it:

14:13:20 [INFO]  Exit from tray icon
14:13:20 [INFO]  Waiting for all pending request to be sent to display (5s max)...
14:13:21 [INFO]  Starting system monitoring
14:13:26 [DEBUG] (Waited 5.1s)

The runs with this change that also exited with data still queued drained in 1.5-2.5 s and the
wait returned normally. One main run that exited with the queue nearly drained behaved the same
as the equivalent run with this change (0.3 s vs 0.4 s), as expected: the failure needs data still
queued behind the turn-off request.

The panel itself went dark in all 10 runs, so the symptom reported in #907 did not reproduce in
that round. That is what the second half of this PR turned out to be about.


2. On Windows, the program was never told the session was ending

Problem

Shutting Windows down left the panel lit, showing the last frame it had received, frozen.
It matters on any board that keeps its USB ports powered in soft-off — mine keeps +5V on
them with the BIOS at its default setting, so the panel simply holds whatever it was last
sent, for as long as the machine is plugged in.

The program is not turning the display off on the way down; it never runs that code at all.
Across two shutdowns the log holds no exit line — not Program will now exit, not
Caught Windows window message event, not Computer is going to sleep — while the same
build logs the whole sequence when it exits from the tray icon. logging's file handler
flushes on every record, so those lines are not lost buffers: the code did not run.

Neither notification path can work for a program started at logon by the task scheduler
with pythonw.exe:

  • SetConsoleCtrlHandler needs a console, and pythonw.exe has none. It also did not list
    CTRL_LOGOFF_EVENT and CTRL_SHUTDOWN_EVENT.
  • The hidden window is created — a debug line added here confirms it — but a program with no
    visible window is not a reliable target for the end-of-session broadcast, and nothing in
    the broadcast path is guaranteed to reach it. It also only polled for messages every
    0.5 s, and it answered WM_QUERYENDSESSION by exiting instead of returning TRUE, leaving
    the system free to terminate it in the middle of the transfer.

For scale, on this machine the shutdown budget is about 12 s: the user's click is logged at
00:05:42 (User32 1074) and the kernel starts going down at 00:05:54 (Kernel-Power 42).

Changes

All in main.py, no new dependency — pywin32 is already required on Windows, and the two
functions it does not expose are reached through ctypes.

  • Ask for session notifications with WTSRegisterSessionNotification and handle
    WM_WTSSESSION_CHANGE. That one is delivered to the window that registered for it, and a
    log off happens in every shutdown, a hybrid shutdown included.
  • Answer WM_QUERYENDSESSION the way it is meant to be answered: hold the shutdown with
    ShutdownBlockReasonCreate, turn the display off, clear the reason, return TRUE. The
    turn-off is done there rather than on WM_ENDSESSION because that message is not
    guaranteed to arrive — once every application has answered, the system may terminate them.
  • Exit on WM_ENDSESSION, unless its wParam says the session is not ending after all.
  • Raise the shutdown notification priority with SetProcessShutdownParameters, so this
    program is notified before ordinary applications rather than at the end of that budget.
  • Replace the PumpWaitingMessages() + sleep(0.5) loop with PumpMessages(), which
    dispatches on arrival and costs no CPU in between.
  • Wait for the queue to drain when suspending too. PBT_APMSUSPEND queued the turn-off and
    returned immediately, leaving the transfer to be cut by the suspend transition.
  • Split clean_stop() into turning the display off and exiting the program, and guard the
    first half with a lock. Several of these events can now fire at once, and running the
    sequence twice would cut the data being sent.
  • Add CTRL_LOGOFF_EVENT and CTRL_SHUTDOWN_EVENT to the console handler, which is the
    path that does work when the program is run from a console.

On hardware

Turing 3.5" (rev A), Windows 11, program started at logon by the task scheduler with
pythonw.exe. Verified by sending the hidden window the same two messages the system sends
at shutdown, with SendMessageTimeout:

09:17:04 [DEBUG] Windows event window created (handle 1770116)
09:17:39 [DEBUG] Caught Windows window message event 0x0011 (wParam 0x0)
09:17:39 [INFO]  Session is ending, display will turn off
09:17:39 [INFO]  Waiting for all pending request to be sent to display (2s max)...
09:17:40 [DEBUG] (Waited 0.4s)
09:17:40 [DEBUG] Caught Windows window message event 0x0016 (wParam 0x1)
09:17:40 [INFO]  Program will now exit

The program answers TRUE in 0.4 s, the panel goes dark, the process exits with no orphan
left behind.

What that does and does not establish. It establishes that the handler does the right
thing, in the right order, within the budget, once the message arrives. It does not
establish that the message now arrives during a real shutdown, which is the half of the
problem that the delivery changes above are aimed at — that needs a real shutdown, and I
will report back on it. Note also that an injected test has to run elevated: this program
runs elevated for LibreHardwareMonitor, so an unelevated SendMessage to its window
fails with ACCESS_DENIED under UIPI. The real messages come from csrss and are not
subject to that.


Notes

  • is_queue_empty() reads Queue.unfinished_tasks. Only its behaviour is documented (via
    Queue.join()), not the
    attribute name, so this relies on a CPython implementation detail. The public alternative is
    Queue.join(), which offers no timeout — both callers here need a bounded wait. Happy to
    switch to a lock-protected counter if you prefer.
  • The try/except in Display.close() is load-bearing, not defensive: LcdSimulated.closeSerial()
    raised AttributeError when the web server never started. That is fixed at the source in this
    PR, but the guard stays so closing can never block the exit.
  • LcdCommTuringUSB never sets lcd_serial, so Display.close() is a no-op for TUR_USB, and
    that revision never used the update queue either. Neither half of this PR changes its behaviour.
  • WM_WTSSESSION_CHANGE, WTS_SESSION_LOGOFF and ShutdownBlockReason{Create,Destroy} are
    not exposed by pywin32, hence the two constants and the small ctypes helper.
  • The two halves are separate commits and are independent of each other. Happy to split the
    second one into its own PR if you would rather review them apart.

Exiting from the tray icon could leave the panel lit: the screen-off command was
cut in the middle of being sent, or was followed by one last sensor frame.

`is_queue_empty()` checked `update_queue.empty()`, which becomes true the moment
`QueueHandler` takes a request out of the queue, not when the write to the serial
port completes. `wait_for_empty_queue()` in `clean_stop()` returned too early and
`os._exit(0)` interrupted the transfer.

`periodic()` also ran `action()` one last time after `STOPPING` was set: the
`if not STOPPING` only skipped the re-scheduling. Sensor threads therefore queued
a frame behind the turn-off requests `clean_stop()` had just queued.

- `is_queue_empty()` now uses the queue count of unfinished tasks, which only drops
  back to zero after `task_done()`, i.e. once the request has really been processed
- `periodic()` no longer runs the action while the program is stopping
- `QueueHandler` becomes its own loop with `get(timeout=)`: the scheduled job blocked
  in an untimed `get()` and never noticed `STOPPING`. An exception raised by a request
  is now logged instead of killing the only consumer of the queue
- `clean_stop()` waits for the queue handler thread before closing the port: a late
  write would fail on the closed port and `WriteLine()` would reopen it
- new `Display.close()`, since `os._exit(0)` skips the driver destructors
- `LcdSimulated` no longer raises `AttributeError` on close when its web server did
  not start because the port was already in use, a path only reached now

Reproducing the real `clean_stop()` order with a slow simulated write:

  before: the wait returns while the last command is still being sent (truncated)
  after:  the turn-off commands are fully sent before exiting

And with a real scheduled job, counting the extra frame queued after `STOPPING`:

  before: 1
  after:  0
On a board that keeps its USB ports powered once the computer is off, shutting
Windows down left the panel lit showing the last frame it had received. The
program was never told the session was ending: across two shutdowns the log
holds no exit line at all, while the same build exits cleanly from the tray.

Neither notification path could work for a program started at logon by the task
scheduler with pythonw.exe:

- SetConsoleCtrlHandler needs a console, and pythonw.exe has none. It also did
  not list CTRL_LOGOFF_EVENT and CTRL_SHUTDOWN_EVENT.
- The hidden window is created, but a program with no visible window is not a
  reliable target for the end of session broadcast. It also polled for messages
  every 0.5s, and answered WM_QUERYENDSESSION by exiting instead of returning
  TRUE, so the system was free to terminate it in the middle of the transfer.

Changes:

- Ask for session notifications with WTSRegisterSessionNotification and handle
  WM_WTSSESSION_CHANGE: that one is delivered to the window that registered for
  it. Log off happens in every shutdown, a hybrid shutdown included.
- Answer WM_QUERYENDSESSION properly: hold the shutdown with a block reason,
  turn the display off, then return TRUE. The turn-off is done there rather
  than on WM_ENDSESSION because that message is not guaranteed to arrive.
- Exit on WM_ENDSESSION, unless its wParam says the session is not ending
  after all.
- Raise the shutdown notification priority with SetProcessShutdownParameters,
  so the command reaches the display before the system starts going down.
- Replace the polling loop with PumpMessages, which dispatches on arrival.
- Wait for the queue to drain when suspending too: the transfer was left to be
  cut by the suspend transition.
- Split clean_stop() into turning the display off and exiting the program, and
  guard the first half with a lock: several of these events can now fire at
  once, and running the sequence twice would cut the data being sent.
- Add CTRL_LOGOFF_EVENT and CTRL_SHUTDOWN_EVENT to the console handler, for the
  usual case of running the program from a console.

Tested on a Turing 3.5" rev A on Windows 11 by sending the window the same
messages the system sends at shutdown: the program answers TRUE in 0.4s, the
panel goes dark and the process exits with no orphan left behind.
@boscocp boscocp changed the title Make sure the screen-off command reaches the display before exiting Turn the display off reliably when the program exits or the Windows session ends Sep 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant