Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
is_queue_empty()returnedupdate_queue.empty(), which becomes true the momentQueueHandlertakes a request out of the queue — not when the write to the serial portcompletes. So
wait_for_empty_queue()inclean_stop()returned early andos._exit(0)cut the screen-off command mid-transfer.
periodic()ranaction()one last time afterSTOPPINGwas set (theif not STOPPINGonly 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 zeroafter
task_done()— i.e. once the request has actually been processed.periodic()no longer runs the action while the program is stopping.QueueHandlerbecomes its own loop withget(timeout=). The scheduled job blocked in anuntimed
get()and never noticedSTOPPING. An exception raised by a request is now loggedinstead of killing the queue's only consumer.
clean_stop()waits for the queue handler thread before closing the port: a late writewould fail on the closed port, and
WriteLine()reopens the port when that happens.Display.close(), sinceos._exit(0)skips the driver destructors.LcdSimulatedno longer raisesAttributeErroron close when its web server did not start(port already in use) — a path only reached now that close actually runs.
Measurements
STOPPING(real scheduled job)How wide the truncation window is depends on what is last in the queue. On an idle exit on
rev A only
SCREEN_OFFis queued —SetBackplateLedColoris 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. Thesensor jobs started after
STOPPINGhad been set and queued a frame each behind the turn-offrequest, so the queue never drained and the exit truncated it:
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
mainrun that exited with the queue nearly drained behaved the sameas 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, notCaught Windows window message event, notComputer is going to sleep— while the samebuild logs the whole sequence when it exits from the tray icon.
logging's file handlerflushes 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:SetConsoleCtrlHandlerneeds a console, andpythonw.exehas none. It also did not listCTRL_LOGOFF_EVENTandCTRL_SHUTDOWN_EVENT.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_QUERYENDSESSIONby exiting instead of returning TRUE, leavingthe 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 at00:05:54(Kernel-Power 42).Changes
All in
main.py, no new dependency —pywin32is already required on Windows, and the twofunctions it does not expose are reached through
ctypes.WTSRegisterSessionNotificationand handleWM_WTSSESSION_CHANGE. That one is delivered to the window that registered for it, and alog off happens in every shutdown, a hybrid shutdown included.
WM_QUERYENDSESSIONthe way it is meant to be answered: hold the shutdown withShutdownBlockReasonCreate, turn the display off, clear the reason, return TRUE. Theturn-off is done there rather than on
WM_ENDSESSIONbecause that message is notguaranteed to arrive — once every application has answered, the system may terminate them.
WM_ENDSESSION, unless itswParamsays the session is not ending after all.SetProcessShutdownParameters, so thisprogram is notified before ordinary applications rather than at the end of that budget.
PumpWaitingMessages()+sleep(0.5)loop withPumpMessages(), whichdispatches on arrival and costs no CPU in between.
PBT_APMSUSPENDqueued the turn-off andreturned immediately, leaving the transfer to be cut by the suspend transition.
clean_stop()into turning the display off and exiting the program, and guard thefirst half with a lock. Several of these events can now fire at once, and running the
sequence twice would cut the data being sent.
CTRL_LOGOFF_EVENTandCTRL_SHUTDOWN_EVENTto the console handler, which is thepath 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 sendsat shutdown, with
SendMessageTimeout: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 unelevatedSendMessageto its windowfails with
ACCESS_DENIEDunder UIPI. The real messages come fromcsrssand are notsubject to that.
Notes
is_queue_empty()readsQueue.unfinished_tasks. Only its behaviour is documented (viaQueue.join()), not theattribute 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 toswitch to a lock-protected counter if you prefer.
try/exceptinDisplay.close()is load-bearing, not defensive:LcdSimulated.closeSerial()raised
AttributeErrorwhen the web server never started. That is fixed at the source in thisPR, but the guard stays so closing can never block the exit.
LcdCommTuringUSBnever setslcd_serial, soDisplay.close()is a no-op forTUR_USB, andthat revision never used the update queue either. Neither half of this PR changes its behaviour.
WM_WTSSESSION_CHANGE,WTS_SESSION_LOGOFFandShutdownBlockReason{Create,Destroy}arenot exposed by
pywin32, hence the two constants and the smallctypeshelper.second one into its own PR if you would rather review them apart.