CreativePython 1.1.1__py3-none-any.whl → 1.1.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- CreativePython/GuiHandler.py +249 -66
- CreativePython/GuiRenderer.py +386 -359
- {creativepython-1.1.1.dist-info → creativepython-1.1.3.dist-info}/METADATA +1 -1
- {creativepython-1.1.1.dist-info → creativepython-1.1.3.dist-info}/RECORD +10 -10
- gui.py +11 -11
- image.py +64 -11
- {creativepython-1.1.1.dist-info → creativepython-1.1.3.dist-info}/WHEEL +0 -0
- {creativepython-1.1.1.dist-info → creativepython-1.1.3.dist-info}/licenses/LICENSE +0 -0
- {creativepython-1.1.1.dist-info → creativepython-1.1.3.dist-info}/licenses/LICENSE-PSF +0 -0
- {creativepython-1.1.1.dist-info → creativepython-1.1.3.dist-info}/top_level.txt +0 -0
CreativePython/GuiHandler.py
CHANGED
|
@@ -15,8 +15,13 @@
|
|
|
15
15
|
|
|
16
16
|
import multiprocessing
|
|
17
17
|
import threading
|
|
18
|
+
import queue
|
|
18
19
|
import atexit
|
|
19
20
|
|
|
21
|
+
# Sender thread tick rate. High enough that the final partial batch after a
|
|
22
|
+
# burst (e.g. a tight for loop) reaches Qt within a few milliseconds.
|
|
23
|
+
_FLUSH_RATE = 200
|
|
24
|
+
_MAX_BUFFER_SIZE = 512 # flush inline when buffer reaches this size
|
|
20
25
|
|
|
21
26
|
#######################################################################################
|
|
22
27
|
# Message Protocol Helpers
|
|
@@ -61,18 +66,28 @@ def _createEvent(eventType, target, args=None):
|
|
|
61
66
|
# Child process entry point
|
|
62
67
|
#######################################################################################
|
|
63
68
|
|
|
64
|
-
def _launchRenderer(childConn):
|
|
69
|
+
def _launchRenderer(childConn, adminChildConn, adminParentConn):
|
|
65
70
|
"""
|
|
66
71
|
Entry point for the GuiRenderer child process.
|
|
67
72
|
Defined here so GuiHandler can reference it without importing from GuiRenderer
|
|
68
73
|
globally (which would also import Qt/PySide6 in the parent process).
|
|
69
74
|
|
|
75
|
+
adminChildConn is the child end of the duplex admin pipe; the child watches it
|
|
76
|
+
with QSocketNotifier. Admin commands (setRate, getRate) arrive here, bypassing
|
|
77
|
+
the command buffer. When the parent closes its end (adminParentConn), the child
|
|
78
|
+
receives EOF and initiates shutdown.
|
|
79
|
+
|
|
80
|
+
adminParentConn is the parent's end; the child closes it immediately so that
|
|
81
|
+
only the parent holds it — ensuring the child sees EOF when the parent closes.
|
|
82
|
+
|
|
70
83
|
At runtime this function only executes in the child process.
|
|
71
84
|
"""
|
|
72
85
|
import sys, os
|
|
73
86
|
|
|
74
|
-
#
|
|
75
|
-
|
|
87
|
+
adminParentConn.close() # child must not hold the parent end open
|
|
88
|
+
|
|
89
|
+
# Suppress macOS system noise that would confuse students.
|
|
90
|
+
# Redirect at the OS level (fd 2) so that
|
|
76
91
|
# C-level libraries (Cocoa, XPC) are also silenced.
|
|
77
92
|
_devnull = os.open(os.devnull, os.O_WRONLY)
|
|
78
93
|
os.dup2(_devnull, 2)
|
|
@@ -80,7 +95,7 @@ def _launchRenderer(childConn):
|
|
|
80
95
|
sys.stderr = open(os.devnull, 'w')
|
|
81
96
|
|
|
82
97
|
from CreativePython.GuiRenderer import GuiRenderer
|
|
83
|
-
renderer = GuiRenderer(childConn)
|
|
98
|
+
renderer = GuiRenderer(childConn, adminChildConn)
|
|
84
99
|
renderer.run()
|
|
85
100
|
|
|
86
101
|
|
|
@@ -123,23 +138,33 @@ class GuiHandler:
|
|
|
123
138
|
# which would re-run the user's script. Used on Windows where
|
|
124
139
|
# fork is unavailable.
|
|
125
140
|
#
|
|
126
|
-
#
|
|
127
|
-
#
|
|
128
|
-
#
|
|
141
|
+
# On macOS, fork() is unsafe whenever the parent has any CoreFoundation /
|
|
142
|
+
# Cocoa state — not just when tkinter is explicitly imported. The Python
|
|
143
|
+
# runtime on macOS initialises parts of CoreFoundation at startup, so a
|
|
144
|
+
# forked child that then creates a QApplication reliably triggers the
|
|
145
|
+
# "You MUST exec()" crash. forkserver avoids this by forking from a clean
|
|
146
|
+
# helper process that has never touched Cocoa.
|
|
147
|
+
# On Windows, spawn is required (no fork).
|
|
148
|
+
# Inside a PyInstaller frozen binary, forkserver can't work (sys.executable
|
|
149
|
+
# is the frozen app, not a Python interpreter), so spawn is used instead.
|
|
129
150
|
import sys
|
|
130
151
|
if sys.platform == 'win32':
|
|
131
152
|
ctx = multiprocessing.get_context('spawn')
|
|
132
153
|
elif getattr(sys, 'frozen', False):
|
|
133
|
-
# Running inside a PyInstaller frozen executable. forkserver spawns
|
|
134
|
-
# its helper by calling sys.executable as a Python interpreter, which
|
|
135
|
-
# doesn't work when sys.executable is the frozen app binary. spawn
|
|
136
|
-
# is safe here because freeze_support() in the entry point intercepts
|
|
137
|
-
# the --multiprocessing-fork flag before any app code runs.
|
|
138
154
|
ctx = multiprocessing.get_context('spawn')
|
|
139
|
-
elif '
|
|
155
|
+
elif sys.platform == 'darwin':
|
|
140
156
|
ctx = multiprocessing.get_context('forkserver')
|
|
141
157
|
else:
|
|
142
158
|
ctx = multiprocessing.get_context('fork')
|
|
159
|
+
|
|
160
|
+
# Dedicated duplex admin pipe. The parent holds adminParentConn; the child
|
|
161
|
+
# holds adminChildConn. Admin commands (setRate, getRate) travel here,
|
|
162
|
+
# bypassing the command buffer entirely. Closing adminParentConn sends EOF
|
|
163
|
+
# to the child, which Qt detects via QSocketNotifier and treats as shutdown —
|
|
164
|
+
# independent of the command queue and independent of whether atexit runs.
|
|
165
|
+
adminChildConn, adminParentConn = multiprocessing.Pipe(duplex=True)
|
|
166
|
+
self._adminConn = adminParentConn
|
|
167
|
+
|
|
143
168
|
# With 'spawn' on POSIX (fork+exec), the forked helper calls
|
|
144
169
|
# get_preparation_data() which reads sys.modules['__main__'].__file__.
|
|
145
170
|
# When a user script is running in PENCIL's subprocess, __main__.__file__
|
|
@@ -159,7 +184,7 @@ class GuiHandler:
|
|
|
159
184
|
try:
|
|
160
185
|
self.childProcess = ctx.Process(
|
|
161
186
|
target = _launchRenderer,
|
|
162
|
-
args = (childConn,),
|
|
187
|
+
args = (childConn, adminChildConn, adminParentConn),
|
|
163
188
|
daemon = True # child is killed automatically if parent dies unexpectedly
|
|
164
189
|
)
|
|
165
190
|
self.childProcess.start()
|
|
@@ -169,7 +194,8 @@ class GuiHandler:
|
|
|
169
194
|
_main_mod.__file__ = _saved_file
|
|
170
195
|
if _saved_spec is not _UNSET:
|
|
171
196
|
_main_mod.__spec__ = _saved_spec
|
|
172
|
-
childConn.close()
|
|
197
|
+
childConn.close() # parent no longer needs the child end of the command pipe
|
|
198
|
+
adminChildConn.close() # parent no longer needs the child end of the admin pipe
|
|
173
199
|
|
|
174
200
|
# responseId counter — each sendQuery() call gets a unique ID so the
|
|
175
201
|
# listener thread can route the response back to the correct caller
|
|
@@ -190,14 +216,51 @@ class GuiHandler:
|
|
|
190
216
|
# must acquire this lock before writing to the pipe
|
|
191
217
|
self._sendLock = threading.Lock()
|
|
192
218
|
|
|
219
|
+
# command buffer — fire-and-forget commands are queued here and sent as
|
|
220
|
+
# a single batch by the sender thread (or flushed early on a query)
|
|
221
|
+
self._commandBuffer = []
|
|
222
|
+
self._bufferLock = threading.Lock()
|
|
223
|
+
|
|
224
|
+
# flush rate - how often to flush the buffer
|
|
225
|
+
self._flushRate = _FLUSH_RATE
|
|
226
|
+
self._maxBufferSize = _MAX_BUFFER_SIZE
|
|
227
|
+
|
|
228
|
+
# callback active lock — held by the dispatch thread while an event callback
|
|
229
|
+
# is executing. The sender thread checks this non-blocking; if it can't
|
|
230
|
+
# acquire, it skips that flush tick so mid-callback commands don't travel as
|
|
231
|
+
# a partial batch. After the callback, the dispatch thread does one explicit
|
|
232
|
+
# flush to send everything the callback accumulated as a single pipe message.
|
|
233
|
+
self._callbackActive = threading.Lock()
|
|
234
|
+
|
|
235
|
+
# sender thread — wakes every 1/FLUSH_RATE seconds and flushes the buffer
|
|
236
|
+
self._senderStopEvent = threading.Event()
|
|
237
|
+
self._senderThread = threading.Thread(
|
|
238
|
+
target = self._runSenderThread,
|
|
239
|
+
daemon = True
|
|
240
|
+
)
|
|
241
|
+
self._senderThread.start()
|
|
242
|
+
|
|
243
|
+
# event queue — listener thread enqueues incoming events here; dispatch
|
|
244
|
+
# thread drains it and runs callbacks. Decoupling the two threads means
|
|
245
|
+
# callbacks can call sendQuery() without deadlocking the listener.
|
|
246
|
+
self._eventQueue = queue.SimpleQueue()
|
|
247
|
+
|
|
193
248
|
# single listener thread handles both responses and events, since both
|
|
194
249
|
# arrive on the same connection and reading from it on two threads would race
|
|
195
250
|
self._listenerThread = threading.Thread(
|
|
196
251
|
target = self._listenForMessages,
|
|
197
|
-
daemon = True
|
|
252
|
+
daemon = True
|
|
198
253
|
)
|
|
199
254
|
self._listenerThread.start()
|
|
200
255
|
|
|
256
|
+
# dispatch thread runs event callbacks sequentially off the event queue,
|
|
257
|
+
# keeping the listener thread free to receive query responses at all times
|
|
258
|
+
self._dispatchThread = threading.Thread(
|
|
259
|
+
target = self._runDispatchThread,
|
|
260
|
+
daemon = True
|
|
261
|
+
)
|
|
262
|
+
self._dispatchThread.start()
|
|
263
|
+
|
|
201
264
|
atexit.register(self._shutdown)
|
|
202
265
|
|
|
203
266
|
# ── Response ID ───────────────────────────────────────────────────────────
|
|
@@ -214,11 +277,11 @@ class GuiHandler:
|
|
|
214
277
|
|
|
215
278
|
def _listenForMessages(self):
|
|
216
279
|
"""
|
|
217
|
-
Background thread. Reads
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
280
|
+
Background thread. Reads incoming messages from GuiRenderer and routes
|
|
281
|
+
each one: responses unblock waiting sendQuery() callers; events are
|
|
282
|
+
pushed onto _eventQueue for the dispatch thread. Exits when the child
|
|
283
|
+
closes the pipe (EOFError), releases any waiting sendQuery() callers,
|
|
284
|
+
and sends a None sentinel to stop the dispatch thread.
|
|
222
285
|
"""
|
|
223
286
|
while True:
|
|
224
287
|
try:
|
|
@@ -226,9 +289,14 @@ class GuiHandler:
|
|
|
226
289
|
if 'responseId' in message:
|
|
227
290
|
self._handleResponse(message)
|
|
228
291
|
elif 'type' in message:
|
|
229
|
-
self.
|
|
292
|
+
self._eventQueue.put(message)
|
|
230
293
|
except EOFError:
|
|
231
|
-
break
|
|
294
|
+
break
|
|
295
|
+
with self._pendingLock:
|
|
296
|
+
for slot in self._pendingResponses.values():
|
|
297
|
+
slot['values'] = ['shutdown']
|
|
298
|
+
slot['event'].set()
|
|
299
|
+
self._eventQueue.put(None) # sentinel: tell dispatch thread to stop
|
|
232
300
|
|
|
233
301
|
def _handleResponse(self, responseDict):
|
|
234
302
|
"""
|
|
@@ -245,22 +313,91 @@ class GuiHandler:
|
|
|
245
313
|
pendingSlot['values'] = values # store result before signalling
|
|
246
314
|
pendingSlot['event'].set() # unblock the waiting sendQuery() call
|
|
247
315
|
|
|
316
|
+
# ── Sender thread ─────────────────────────────────────────────────────────
|
|
317
|
+
|
|
318
|
+
def _runSenderThread(self):
|
|
319
|
+
"""
|
|
320
|
+
Background thread. Wakes every 1 / _flushRate seconds and flushes the
|
|
321
|
+
command buffer. Skips a tick if a callback is currently executing
|
|
322
|
+
(_callbackActive is held) so the callback's commands don't get split
|
|
323
|
+
across multiple pipe messages. Exits when _senderStopEvent is set.
|
|
324
|
+
"""
|
|
325
|
+
while not self._senderStopEvent.wait(timeout=1.0 / self._flushRate):
|
|
326
|
+
if self._callbackActive.acquire(blocking=False):
|
|
327
|
+
self._callbackActive.release()
|
|
328
|
+
self._flushBuffer()
|
|
329
|
+
# else: callback in progress — skip this tick
|
|
330
|
+
self._flushBuffer() # final drain on shutdown
|
|
331
|
+
|
|
332
|
+
def _flushBuffer(self):
|
|
333
|
+
"""
|
|
334
|
+
Sends the current command buffer as a single batch message, then resets
|
|
335
|
+
the buffer. Thread-safe; concurrent callers are safe — only one will
|
|
336
|
+
get a non-empty batch, the others are no-ops.
|
|
337
|
+
"""
|
|
338
|
+
with self._bufferLock:
|
|
339
|
+
batch = self._commandBuffer
|
|
340
|
+
if not batch:
|
|
341
|
+
return
|
|
342
|
+
self._commandBuffer = []
|
|
343
|
+
with self._sendLock:
|
|
344
|
+
try:
|
|
345
|
+
self.connection.send(batch)
|
|
346
|
+
except (BrokenPipeError, OSError):
|
|
347
|
+
self._senderStopEvent.set() # pipe is gone; stop the sender thread
|
|
348
|
+
|
|
349
|
+
def getFlushRate(self):
|
|
350
|
+
"""
|
|
351
|
+
Returns the current tick rate for flushing the command buffer.
|
|
352
|
+
"""
|
|
353
|
+
return self._flushRate
|
|
354
|
+
|
|
355
|
+
def setFlushRate(self, flushRate):
|
|
356
|
+
"""
|
|
357
|
+
Sets the current tick rate for flushing the command buffer.
|
|
358
|
+
"""
|
|
359
|
+
self._flushRate = flushRate
|
|
360
|
+
|
|
361
|
+
def getRate(self):
|
|
362
|
+
"""
|
|
363
|
+
Returns the current render rate (timer ticks per second) of the Qt renderer.
|
|
364
|
+
Sends directly on the admin pipe, bypassing the command buffer.
|
|
365
|
+
"""
|
|
366
|
+
self._adminConn.send({'action': 'getRate'})
|
|
367
|
+
return self._adminConn.recv()
|
|
368
|
+
|
|
369
|
+
def setRate(self, rate):
|
|
370
|
+
"""
|
|
371
|
+
Sets the render rate (timer ticks per second) of the Qt renderer.
|
|
372
|
+
Sends directly on the admin pipe, bypassing the command buffer.
|
|
373
|
+
"""
|
|
374
|
+
self._adminConn.send({'action': 'setRate', 'rate': rate})
|
|
375
|
+
|
|
248
376
|
# ── Sending ───────────────────────────────────────────────────────────────
|
|
249
377
|
|
|
250
378
|
def sendCommand(self, action, target, args=None):
|
|
251
379
|
"""
|
|
252
|
-
|
|
253
|
-
Thread-safe.
|
|
380
|
+
Queues a fire-and-forget command in the command buffer. The sender thread
|
|
381
|
+
flushes on each tick. Thread-safe.
|
|
254
382
|
"""
|
|
255
383
|
command = _createCommand(action, target, args)
|
|
256
|
-
with self.
|
|
257
|
-
self.
|
|
384
|
+
with self._bufferLock:
|
|
385
|
+
self._commandBuffer.append(command)
|
|
386
|
+
flush_now = len(self._commandBuffer) >= self._maxBufferSize
|
|
387
|
+
if flush_now and self._callbackActive.acquire(blocking=False):
|
|
388
|
+
self._callbackActive.release()
|
|
389
|
+
self._flushBuffer()
|
|
258
390
|
|
|
259
391
|
def sendQuery(self, action, target, args=None):
|
|
260
392
|
"""
|
|
261
393
|
Sends a command to GuiRenderer and blocks until a response is received.
|
|
262
394
|
Returns the response's values list. Thread-safe; multiple callers may
|
|
263
395
|
block concurrently — each is matched to its response by responseId.
|
|
396
|
+
|
|
397
|
+
Flushes any buffered fire-and-forget commands before sending the query
|
|
398
|
+
so that GuiRenderer processes them first and the query reflects up-to-date
|
|
399
|
+
state. The flush and query send are both performed under _sendLock so
|
|
400
|
+
no other thread can interleave a send between them.
|
|
264
401
|
"""
|
|
265
402
|
responseId = self._nextResponseId()
|
|
266
403
|
pendingSlot = {'event': threading.Event(), 'values': None}
|
|
@@ -270,8 +407,16 @@ class GuiHandler:
|
|
|
270
407
|
self._pendingResponses[responseId] = pendingSlot
|
|
271
408
|
|
|
272
409
|
command = _createCommand(action, target, args, responseId=responseId)
|
|
410
|
+
|
|
411
|
+
# grab any buffered commands, then send them + the query atomically as one list
|
|
412
|
+
with self._bufferLock:
|
|
413
|
+
batch = self._commandBuffer
|
|
414
|
+
self._commandBuffer = []
|
|
273
415
|
with self._sendLock:
|
|
274
|
-
|
|
416
|
+
try:
|
|
417
|
+
self.connection.send(batch + [command])
|
|
418
|
+
except (BrokenPipeError, OSError):
|
|
419
|
+
pass # pipe is gone; listener thread will release the pending slot via EOFError
|
|
275
420
|
|
|
276
421
|
pendingSlot['event'].wait() # block until the listener signals a response
|
|
277
422
|
|
|
@@ -280,25 +425,6 @@ class GuiHandler:
|
|
|
280
425
|
|
|
281
426
|
return pendingSlot['values']
|
|
282
427
|
|
|
283
|
-
# ── Developer utilities ───────────────────────────────────────────────────
|
|
284
|
-
|
|
285
|
-
def getBatchSize(self):
|
|
286
|
-
"""
|
|
287
|
-
Returns the current _BATCH_SIZE used by GuiRenderer's pipe-polling loop.
|
|
288
|
-
Not part of the public API — intended for developer tuning only.
|
|
289
|
-
"""
|
|
290
|
-
result = self.sendQuery('getBatchSize', None)
|
|
291
|
-
return result[0]
|
|
292
|
-
|
|
293
|
-
def setBatchSize(self, batchSize):
|
|
294
|
-
"""
|
|
295
|
-
Sets GuiRenderer's _BATCH_SIZE on the fly.
|
|
296
|
-
Higher values process more commands between Qt repaints (better throughput,
|
|
297
|
-
less responsive feel); lower values yield more frequent repaints.
|
|
298
|
-
Not part of the public API — intended for developer tuning only.
|
|
299
|
-
"""
|
|
300
|
-
self.sendCommand('setBatchSize', None, {'batchSize': batchSize})
|
|
301
|
-
|
|
302
428
|
# ── Events ────────────────────────────────────────────────────────────────
|
|
303
429
|
|
|
304
430
|
def registerEvent(self, objectId, eventType, callback):
|
|
@@ -314,10 +440,70 @@ class GuiHandler:
|
|
|
314
440
|
registrationArgs = {'objectId': objectId, 'eventType': eventType}
|
|
315
441
|
self.sendCommand('registerEvent', None, registrationArgs)
|
|
316
442
|
|
|
443
|
+
def _runDispatchThread(self):
|
|
444
|
+
"""
|
|
445
|
+
Background thread. Drains _eventQueue and runs event callbacks
|
|
446
|
+
sequentially. Decoupled from the listener thread so callbacks can call
|
|
447
|
+
sendQuery() without deadlocking the listener.
|
|
448
|
+
|
|
449
|
+
Coalesces consecutive position events (mouseMove, mouseDrag) for the same
|
|
450
|
+
target: when multiple are queued back-to-back, only the last one is
|
|
451
|
+
dispatched. This prevents lag when a callback involves a query round-trip
|
|
452
|
+
(e.g. intersects()) that causes events to accumulate faster than they are
|
|
453
|
+
consumed. All other event types (clicks, keys, enter/exit) are always
|
|
454
|
+
dispatched in full order.
|
|
455
|
+
|
|
456
|
+
Exits when it receives the None sentinel pushed by _listenForMessages.
|
|
457
|
+
"""
|
|
458
|
+
_COALESCE = frozenset({'mouseMove', 'mouseDrag'})
|
|
459
|
+
|
|
460
|
+
while True:
|
|
461
|
+
# block until at least one event is available
|
|
462
|
+
batch = [self._eventQueue.get()]
|
|
463
|
+
if batch[0] is None:
|
|
464
|
+
break
|
|
465
|
+
|
|
466
|
+
# drain any additional immediately-available events into the batch
|
|
467
|
+
try:
|
|
468
|
+
while True:
|
|
469
|
+
msg = self._eventQueue.get(block=False)
|
|
470
|
+
batch.append(msg)
|
|
471
|
+
if msg is None:
|
|
472
|
+
break # sentinel — process batch then exit
|
|
473
|
+
except queue.Empty:
|
|
474
|
+
pass
|
|
475
|
+
|
|
476
|
+
# process the batch in order, coalescing consecutive position events
|
|
477
|
+
i = 0
|
|
478
|
+
while i < len(batch):
|
|
479
|
+
msg = batch[i]
|
|
480
|
+
if msg is None:
|
|
481
|
+
return # sentinel reached mid-batch
|
|
482
|
+
|
|
483
|
+
eventType = msg.get('type')
|
|
484
|
+
if eventType in _COALESCE:
|
|
485
|
+
target = msg.get('target')
|
|
486
|
+
# find the last consecutive event of the same type+target
|
|
487
|
+
j = i + 1
|
|
488
|
+
while (j < len(batch)
|
|
489
|
+
and batch[j] is not None
|
|
490
|
+
and batch[j].get('type') == eventType
|
|
491
|
+
and batch[j].get('target') == target):
|
|
492
|
+
j += 1
|
|
493
|
+
with self._callbackActive:
|
|
494
|
+
self._dispatchEvent(batch[j - 1]) # skip stale positions
|
|
495
|
+
self._flushBuffer()
|
|
496
|
+
i = j
|
|
497
|
+
else:
|
|
498
|
+
with self._callbackActive:
|
|
499
|
+
self._dispatchEvent(msg)
|
|
500
|
+
self._flushBuffer()
|
|
501
|
+
i += 1
|
|
502
|
+
|
|
317
503
|
def _dispatchEvent(self, eventDict):
|
|
318
504
|
"""
|
|
319
505
|
Looks up and calls the callback registered for the incoming event.
|
|
320
|
-
Called from the
|
|
506
|
+
Called from the dispatch thread.
|
|
321
507
|
"""
|
|
322
508
|
eventType = eventDict.get('type')
|
|
323
509
|
objectId = eventDict.get('target')
|
|
@@ -334,23 +520,18 @@ class GuiHandler:
|
|
|
334
520
|
|
|
335
521
|
def _shutdown(self):
|
|
336
522
|
"""
|
|
337
|
-
|
|
523
|
+
Signals the child to shut down and waits for it to exit.
|
|
524
|
+
Stops the sender thread first so any remaining buffered commands are
|
|
525
|
+
flushed before the pipe closes. Closing the shutdown pipe delivers EOF
|
|
526
|
+
to the child's QSocketNotifier, which fires _onShutdownSignal on Qt's
|
|
527
|
+
main thread immediately — even if atexit does not run, since the OS
|
|
528
|
+
closes the pipe fd on process death.
|
|
338
529
|
Called automatically via atexit when the parent process exits.
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
# bypassing Qt's graceful shutdown (which drains the event queue).
|
|
345
|
-
# Python delivers SIGTERM between bytecodes, so it fires within one
|
|
346
|
-
# iteration of the child's _pollPipe loop — far faster than SIGKILL,
|
|
347
|
-
# which macOS may defer while a Metal/GPU operation is in flight.
|
|
348
|
-
self.childProcess.terminate()
|
|
349
|
-
self.childProcess.join(timeout=0.5) # should die in < 1 ms
|
|
350
|
-
if self.childProcess.is_alive():
|
|
351
|
-
self.childProcess.kill() # backup: SIGKILL
|
|
352
|
-
self.childProcess.join(timeout=2.0)
|
|
353
|
-
self.connection.close()
|
|
530
|
+
"""
|
|
531
|
+
self._senderStopEvent.set()
|
|
532
|
+
self._senderThread.join(timeout=1.0)
|
|
533
|
+
self._adminConn.close() # EOF on child's admin pipe triggers shutdown
|
|
534
|
+
self.childProcess.join(timeout=1.0)
|
|
354
535
|
|
|
355
536
|
|
|
356
537
|
#######################################################################################
|
|
@@ -370,8 +551,10 @@ class _NullHandler:
|
|
|
370
551
|
def sendCommand(self, action, target, args=None): pass
|
|
371
552
|
def sendQuery(self, action, target, args=None): return [0, 0, 0, 0]
|
|
372
553
|
def registerEvent(self, objectId, eventType, callback): pass
|
|
373
|
-
def
|
|
374
|
-
def
|
|
554
|
+
def getFlushRate(self): return _FLUSH_RATE
|
|
555
|
+
def setFlushRate(self, flushRate): pass
|
|
556
|
+
def getRate(self): return _FLUSH_RATE
|
|
557
|
+
def setRate(self, _rate): pass
|
|
375
558
|
|
|
376
559
|
|
|
377
560
|
def _createHandler():
|