uhttp-workers 1.1.0__tar.gz → 1.3.0__tar.gz

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.
Files changed (25) hide show
  1. {uhttp_workers-1.1.0/uhttp_workers.egg-info → uhttp_workers-1.3.0}/PKG-INFO +153 -1
  2. {uhttp_workers-1.1.0 → uhttp_workers-1.3.0}/README.md +152 -0
  3. uhttp_workers-1.3.0/examples/sse_workers.py +80 -0
  4. {uhttp_workers-1.1.0 → uhttp_workers-1.3.0}/tests/test_dispatcher.py +139 -0
  5. uhttp_workers-1.3.0/tests/test_worker.py +741 -0
  6. {uhttp_workers-1.1.0 → uhttp_workers-1.3.0}/uhttp/workers.py +219 -18
  7. {uhttp_workers-1.1.0 → uhttp_workers-1.3.0/uhttp_workers.egg-info}/PKG-INFO +153 -1
  8. {uhttp_workers-1.1.0 → uhttp_workers-1.3.0}/uhttp_workers.egg-info/SOURCES.txt +1 -0
  9. uhttp_workers-1.1.0/tests/test_worker.py +0 -327
  10. {uhttp_workers-1.1.0 → uhttp_workers-1.3.0}/.github/workflows/publish.yml +0 -0
  11. {uhttp_workers-1.1.0 → uhttp_workers-1.3.0}/.github/workflows/tests.yml +0 -0
  12. {uhttp_workers-1.1.0 → uhttp_workers-1.3.0}/.gitignore +0 -0
  13. {uhttp_workers-1.1.0 → uhttp_workers-1.3.0}/examples/simple_workers.py +0 -0
  14. {uhttp_workers-1.1.0 → uhttp_workers-1.3.0}/examples/static/index.html +0 -0
  15. {uhttp_workers-1.1.0 → uhttp_workers-1.3.0}/pyproject.toml +0 -0
  16. {uhttp_workers-1.1.0 → uhttp_workers-1.3.0}/setup.cfg +0 -0
  17. {uhttp_workers-1.1.0 → uhttp_workers-1.3.0}/tests/__init__.py +0 -0
  18. {uhttp_workers-1.1.0 → uhttp_workers-1.3.0}/tests/test_api_handler.py +0 -0
  19. {uhttp_workers-1.1.0 → uhttp_workers-1.3.0}/tests/test_decorators.py +0 -0
  20. {uhttp_workers-1.1.0 → uhttp_workers-1.3.0}/tests/test_pattern_matching.py +0 -0
  21. {uhttp_workers-1.1.0 → uhttp_workers-1.3.0}/tests/test_request_response.py +0 -0
  22. {uhttp_workers-1.1.0 → uhttp_workers-1.3.0}/tests/test_worker_pool.py +0 -0
  23. {uhttp_workers-1.1.0 → uhttp_workers-1.3.0}/uhttp_workers.egg-info/dependency_links.txt +0 -0
  24. {uhttp_workers-1.1.0 → uhttp_workers-1.3.0}/uhttp_workers.egg-info/requires.txt +0 -0
  25. {uhttp_workers-1.1.0 → uhttp_workers-1.3.0}/uhttp_workers.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: uhttp-workers
3
- Version: 1.1.0
3
+ Version: 1.3.0
4
4
  Summary: Multi-process worker dispatcher built on uhttp-server
5
5
  Author-email: Pavel Revak <pavelrevak@gmail.com>
6
6
  License-Expression: MIT
@@ -234,6 +234,21 @@ class MyWorker(_workers.Worker):
234
234
 
235
235
  Extra keyword arguments from `WorkerPool(...)` are available as `self.kwargs`.
236
236
 
237
+ ### Teardown
238
+
239
+ `teardown()` is called once when the worker process is stopping — use it to release resources:
240
+
241
+ ```python
242
+ class MyWorker(_workers.Worker):
243
+ def setup(self):
244
+ self.db = connect_db()
245
+
246
+ def teardown(self):
247
+ self.db.close()
248
+ ```
249
+
250
+ Called after the run loop exits (clean stop, orphan detection, or pipe close), before the process terminates. Exceptions are logged but do not block shutdown.
251
+
237
252
  ### Configuration Updates
238
253
 
239
254
  Dispatcher can send configuration to workers at runtime via per-worker control queues:
@@ -277,8 +292,120 @@ def process(self, request):
277
292
 
278
293
  # return data with status
279
294
  return {'error': 'not found'}, 404
295
+
296
+ # return data with status and headers
297
+ return {'ok': True}, 200, {'X-Custom': 'value'}
298
+
299
+ # return Response object — full control (headers, cookies)
300
+ return _workers.Response(
301
+ None, # request_id is set automatically
302
+ data={'ok': True},
303
+ headers={'X-Custom': 'value'},
304
+ cookies={'session': 'abc123'})
305
+
306
+ # defer response — worker continues accepting requests
307
+ return _workers.DEFERRED
308
+ ```
309
+
310
+ `request.respond()` (for deferred responses) also accepts `headers` and `cookies`:
311
+
312
+ ```python
313
+ request.respond(
314
+ data={'result': 'done'},
315
+ headers={'X-Job-Id': '42'},
316
+ cookies={'session': 'abc'})
280
317
  ```
281
318
 
319
+ ### Deferred Responses
320
+
321
+ Return `DEFERRED` to skip immediate response. The worker stays in the select loop, accepts new requests, and sends the response later via `request.respond()`:
322
+
323
+ ```python
324
+ class MyWorker(_workers.Worker):
325
+ def setup(self):
326
+ self._jobs = {}
327
+
328
+ @_workers.api('/process', 'POST')
329
+ def process(self, request):
330
+ job_id = start_background_work(request.data)
331
+ self._jobs[job_id] = request
332
+ return _workers.DEFERRED
333
+
334
+ def on_work_done(self, job_id, result):
335
+ request = self._jobs.pop(job_id)
336
+ request.respond(data={'result': result})
337
+ ```
338
+
339
+ Note: deferred requests are still subject to dispatcher timeout — call `self.keep_alive()` periodically to prevent 504.
340
+
341
+ ### Keep Alive
342
+
343
+ Call `self.keep_alive()` during long operations to reset both the request timeout and stuck worker detection:
344
+
345
+ ```python
346
+ @_workers.api('/export', 'POST')
347
+ def export(self, request):
348
+ for chunk in generate_large_export():
349
+ self.keep_alive()
350
+ return {'status': 'done'}
351
+ ```
352
+
353
+ ### Server-Sent Events (SSE)
354
+
355
+ Stream events to clients using the same API as `uhttp.server.Client`:
356
+
357
+ ```python
358
+ class MyWorker(_workers.Worker):
359
+ def setup(self):
360
+ self._subscribers = {}
361
+
362
+ @_workers.api('/events', 'GET')
363
+ def events(self, request):
364
+ request.response_stream() # sends headers, keeps connection open
365
+ self._subscribers[request.request_id] = request
366
+ return _workers.DEFERRED
367
+
368
+ def notify(self, data):
369
+ for req in self._subscribers.values():
370
+ req.send_event(data=data, event='update')
371
+
372
+ def on_disconnect(self, request_id):
373
+ self._subscribers.pop(request_id, None)
374
+ ```
375
+
376
+ Available streaming methods on `Request`:
377
+
378
+ | Method | Description |
379
+ |--------|-------------|
380
+ | `response_stream(content_type, headers, cookies)` | Start streaming (default: `text/event-stream`) |
381
+ | `send_event(data, event, event_id, retry)` | Send SSE event |
382
+ | `send_chunk(data)` | Send raw data chunk |
383
+ | `response_stream_end()` | End stream and close connection |
384
+
385
+ Streaming requests are excluded from dispatcher timeout expiration. When the client disconnects, the dispatcher notifies the worker via `on_disconnect(request_id)`.
386
+
387
+ ### Flow Control
388
+
389
+ Workers can stop accepting new requests when busy. Requests stay in the shared pool queue for other workers to pick up:
390
+
391
+ ```python
392
+ class MyWorker(_workers.Worker):
393
+ @_workers.api('/events', 'GET')
394
+ def subscribe(self, request):
395
+ request.response_stream()
396
+ self._subscribers[request.request_id] = request
397
+ if len(self._subscribers) >= 100:
398
+ self.pause()
399
+ return _workers.DEFERRED
400
+
401
+ def on_disconnect(self, request_id):
402
+ self._subscribers.pop(request_id, None)
403
+ if not self._accepting and len(self._subscribers) < 100:
404
+ self.resume()
405
+ ```
406
+
407
+ `pause()` excludes the request queue from `select()` — the worker continues processing control messages, custom fd events, and `on_idle()`. `resume()` re-enables request acceptance.
408
+
282
409
  ## URL Patterns
283
410
 
284
411
  Dispatcher uses prefix matching to route requests to pools:
@@ -339,6 +466,17 @@ Return `(data, status)` tuple to reject, or `None` to continue. You can also rai
339
466
 
340
467
  `RejectRequest` accepts optional `data` (default: `{'error': 'Rejected'}`) and `status` (default: `403`).
341
468
 
469
+ `RejectRequest` can also be raised from request handlers — useful for access control within individual endpoints:
470
+
471
+ ```python
472
+ @_workers.api('/admin/users', 'GET')
473
+ def admin_users(self, request):
474
+ if not self.is_admin(request):
475
+ raise _workers.RejectRequest(
476
+ data={'error': 'admin only'}, status=403)
477
+ return {'users': self.list_users()}
478
+ ```
479
+
342
480
  ### Error Handling
343
481
 
344
482
  Override `on_request_error()` on the worker to customize error handling when a request handler raises an exception:
@@ -430,6 +568,20 @@ class MyWorker(_workers.Worker):
430
568
 
431
569
  Log messages are sent to the dispatcher via the shared response queue and printed in the dispatcher process — no interleaved output from multiple processes.
432
570
 
571
+ The dispatcher itself also has a `self.log` Logger that writes directly via `on_log()` (no queue), so it can be used at any time — including before workers are started:
572
+
573
+ ```python
574
+ class MyDispatcher(_workers.Dispatcher):
575
+ def __init__(self, **kwargs):
576
+ super().__init__(**kwargs)
577
+ self.log.info("Dispatcher starting on port %d", self._port)
578
+
579
+ def on_idle(self):
580
+ self.log.debug("idle tick")
581
+ ```
582
+
583
+ Set dispatcher log level via constructor: `Dispatcher(log_level=LOG_DEBUG, ...)` (default `LOG_INFO`).
584
+
433
585
  **Log levels:** `LOG_DEBUG` (10), `LOG_INFO` (20), `LOG_WARNING` (30), `LOG_ERROR` (40), `LOG_CRITICAL` (50)
434
586
 
435
587
  Check current level with `is_*` properties to skip expensive formatting:
@@ -220,6 +220,21 @@ class MyWorker(_workers.Worker):
220
220
 
221
221
  Extra keyword arguments from `WorkerPool(...)` are available as `self.kwargs`.
222
222
 
223
+ ### Teardown
224
+
225
+ `teardown()` is called once when the worker process is stopping — use it to release resources:
226
+
227
+ ```python
228
+ class MyWorker(_workers.Worker):
229
+ def setup(self):
230
+ self.db = connect_db()
231
+
232
+ def teardown(self):
233
+ self.db.close()
234
+ ```
235
+
236
+ Called after the run loop exits (clean stop, orphan detection, or pipe close), before the process terminates. Exceptions are logged but do not block shutdown.
237
+
223
238
  ### Configuration Updates
224
239
 
225
240
  Dispatcher can send configuration to workers at runtime via per-worker control queues:
@@ -263,8 +278,120 @@ def process(self, request):
263
278
 
264
279
  # return data with status
265
280
  return {'error': 'not found'}, 404
281
+
282
+ # return data with status and headers
283
+ return {'ok': True}, 200, {'X-Custom': 'value'}
284
+
285
+ # return Response object — full control (headers, cookies)
286
+ return _workers.Response(
287
+ None, # request_id is set automatically
288
+ data={'ok': True},
289
+ headers={'X-Custom': 'value'},
290
+ cookies={'session': 'abc123'})
291
+
292
+ # defer response — worker continues accepting requests
293
+ return _workers.DEFERRED
294
+ ```
295
+
296
+ `request.respond()` (for deferred responses) also accepts `headers` and `cookies`:
297
+
298
+ ```python
299
+ request.respond(
300
+ data={'result': 'done'},
301
+ headers={'X-Job-Id': '42'},
302
+ cookies={'session': 'abc'})
266
303
  ```
267
304
 
305
+ ### Deferred Responses
306
+
307
+ Return `DEFERRED` to skip immediate response. The worker stays in the select loop, accepts new requests, and sends the response later via `request.respond()`:
308
+
309
+ ```python
310
+ class MyWorker(_workers.Worker):
311
+ def setup(self):
312
+ self._jobs = {}
313
+
314
+ @_workers.api('/process', 'POST')
315
+ def process(self, request):
316
+ job_id = start_background_work(request.data)
317
+ self._jobs[job_id] = request
318
+ return _workers.DEFERRED
319
+
320
+ def on_work_done(self, job_id, result):
321
+ request = self._jobs.pop(job_id)
322
+ request.respond(data={'result': result})
323
+ ```
324
+
325
+ Note: deferred requests are still subject to dispatcher timeout — call `self.keep_alive()` periodically to prevent 504.
326
+
327
+ ### Keep Alive
328
+
329
+ Call `self.keep_alive()` during long operations to reset both the request timeout and stuck worker detection:
330
+
331
+ ```python
332
+ @_workers.api('/export', 'POST')
333
+ def export(self, request):
334
+ for chunk in generate_large_export():
335
+ self.keep_alive()
336
+ return {'status': 'done'}
337
+ ```
338
+
339
+ ### Server-Sent Events (SSE)
340
+
341
+ Stream events to clients using the same API as `uhttp.server.Client`:
342
+
343
+ ```python
344
+ class MyWorker(_workers.Worker):
345
+ def setup(self):
346
+ self._subscribers = {}
347
+
348
+ @_workers.api('/events', 'GET')
349
+ def events(self, request):
350
+ request.response_stream() # sends headers, keeps connection open
351
+ self._subscribers[request.request_id] = request
352
+ return _workers.DEFERRED
353
+
354
+ def notify(self, data):
355
+ for req in self._subscribers.values():
356
+ req.send_event(data=data, event='update')
357
+
358
+ def on_disconnect(self, request_id):
359
+ self._subscribers.pop(request_id, None)
360
+ ```
361
+
362
+ Available streaming methods on `Request`:
363
+
364
+ | Method | Description |
365
+ |--------|-------------|
366
+ | `response_stream(content_type, headers, cookies)` | Start streaming (default: `text/event-stream`) |
367
+ | `send_event(data, event, event_id, retry)` | Send SSE event |
368
+ | `send_chunk(data)` | Send raw data chunk |
369
+ | `response_stream_end()` | End stream and close connection |
370
+
371
+ Streaming requests are excluded from dispatcher timeout expiration. When the client disconnects, the dispatcher notifies the worker via `on_disconnect(request_id)`.
372
+
373
+ ### Flow Control
374
+
375
+ Workers can stop accepting new requests when busy. Requests stay in the shared pool queue for other workers to pick up:
376
+
377
+ ```python
378
+ class MyWorker(_workers.Worker):
379
+ @_workers.api('/events', 'GET')
380
+ def subscribe(self, request):
381
+ request.response_stream()
382
+ self._subscribers[request.request_id] = request
383
+ if len(self._subscribers) >= 100:
384
+ self.pause()
385
+ return _workers.DEFERRED
386
+
387
+ def on_disconnect(self, request_id):
388
+ self._subscribers.pop(request_id, None)
389
+ if not self._accepting and len(self._subscribers) < 100:
390
+ self.resume()
391
+ ```
392
+
393
+ `pause()` excludes the request queue from `select()` — the worker continues processing control messages, custom fd events, and `on_idle()`. `resume()` re-enables request acceptance.
394
+
268
395
  ## URL Patterns
269
396
 
270
397
  Dispatcher uses prefix matching to route requests to pools:
@@ -325,6 +452,17 @@ Return `(data, status)` tuple to reject, or `None` to continue. You can also rai
325
452
 
326
453
  `RejectRequest` accepts optional `data` (default: `{'error': 'Rejected'}`) and `status` (default: `403`).
327
454
 
455
+ `RejectRequest` can also be raised from request handlers — useful for access control within individual endpoints:
456
+
457
+ ```python
458
+ @_workers.api('/admin/users', 'GET')
459
+ def admin_users(self, request):
460
+ if not self.is_admin(request):
461
+ raise _workers.RejectRequest(
462
+ data={'error': 'admin only'}, status=403)
463
+ return {'users': self.list_users()}
464
+ ```
465
+
328
466
  ### Error Handling
329
467
 
330
468
  Override `on_request_error()` on the worker to customize error handling when a request handler raises an exception:
@@ -416,6 +554,20 @@ class MyWorker(_workers.Worker):
416
554
 
417
555
  Log messages are sent to the dispatcher via the shared response queue and printed in the dispatcher process — no interleaved output from multiple processes.
418
556
 
557
+ The dispatcher itself also has a `self.log` Logger that writes directly via `on_log()` (no queue), so it can be used at any time — including before workers are started:
558
+
559
+ ```python
560
+ class MyDispatcher(_workers.Dispatcher):
561
+ def __init__(self, **kwargs):
562
+ super().__init__(**kwargs)
563
+ self.log.info("Dispatcher starting on port %d", self._port)
564
+
565
+ def on_idle(self):
566
+ self.log.debug("idle tick")
567
+ ```
568
+
569
+ Set dispatcher log level via constructor: `Dispatcher(log_level=LOG_DEBUG, ...)` (default `LOG_INFO`).
570
+
419
571
  **Log levels:** `LOG_DEBUG` (10), `LOG_INFO` (20), `LOG_WARNING` (30), `LOG_ERROR` (40), `LOG_CRITICAL` (50)
420
572
 
421
573
  Check current level with `is_*` properties to skip expensive formatting:
@@ -0,0 +1,80 @@
1
+ """SSE (Server-Sent Events) example with deferred responses.
2
+
3
+ Demonstrates:
4
+ - SSE streaming from worker to client
5
+ - Deferred responses (DEFERRED sentinel)
6
+ - Client disconnect handling (on_disconnect)
7
+ - keep_alive() for long operations
8
+
9
+ Test with:
10
+ curl -N http://localhost:8080/events
11
+ curl -N http://localhost:8080/events?channel=news
12
+ curl -X POST http://localhost:8080/publish -d '{"msg": "hello"}'
13
+ curl -X POST http://localhost:8080/publish -d '{"channel": "news", "msg": "breaking"}'
14
+ """
15
+
16
+ import uhttp.workers as _workers
17
+
18
+
19
+ class EventWorker(_workers.Worker):
20
+ """Worker that manages SSE subscribers and publishes events."""
21
+
22
+ def setup(self):
23
+ self._subscribers = {} # request_id → (request, channel)
24
+
25
+ @_workers.api('/events', 'GET')
26
+ def subscribe(self, request):
27
+ channel = (request.query or {}).get('channel', 'default')
28
+ request.response_stream()
29
+ request.send_event(
30
+ data={'status': 'connected', 'channel': channel},
31
+ event='open')
32
+ self._subscribers[request.request_id] = (request, channel)
33
+ if len(self._subscribers) >= self.kwargs.get('max_subscribers', 100):
34
+ self.pause()
35
+ return _workers.DEFERRED
36
+
37
+ @_workers.api('/publish', 'POST')
38
+ def publish(self, request):
39
+ channel = (request.data or {}).get('channel', 'default')
40
+ msg = (request.data or {}).get('msg', '')
41
+ count = 0
42
+ for req, ch in self._subscribers.values():
43
+ if ch == channel:
44
+ req.send_event(data={'msg': msg}, event='message')
45
+ count += 1
46
+ return {'published': count, 'channel': channel}
47
+
48
+ def on_disconnect(self, request_id):
49
+ sub = self._subscribers.pop(request_id, None)
50
+ if sub:
51
+ self.log.info("Client disconnected: %d", request_id)
52
+ max_subs = self.kwargs.get('max_subscribers', 100)
53
+ if not self._accepting and len(self._subscribers) < max_subs:
54
+ self.resume()
55
+
56
+ def on_idle(self):
57
+ # periodic keepalive to all subscribers
58
+ for req, _ in list(self._subscribers.values()):
59
+ req.send_event(event='ping')
60
+
61
+
62
+ class MyDispatcher(_workers.Dispatcher):
63
+ @_workers.sync('/health')
64
+ def health(self, client, path_params):
65
+ client.respond({'status': 'ok'})
66
+
67
+
68
+ def main():
69
+ pool = _workers.WorkerPool(
70
+ EventWorker, num_workers=2,
71
+ log_level=_workers.LOG_INFO,
72
+ timeout=30)
73
+ dispatcher = MyDispatcher(
74
+ port=8080,
75
+ pools=[pool])
76
+ dispatcher.run()
77
+
78
+
79
+ if __name__ == '__main__':
80
+ main()
@@ -10,6 +10,8 @@ from uhttp.workers import (
10
10
  Dispatcher, Worker, WorkerPool, Request, Response,
11
11
  api, sync, RejectRequest,
12
12
  MSG_RESPONSE, MSG_HEARTBEAT,
13
+ MSG_SSE_OPEN, MSG_SSE_EVENT, MSG_SSE_CLOSE,
14
+ CTL_DISCONNECT,
13
15
  _PendingRequest,
14
16
  )
15
17
 
@@ -52,6 +54,30 @@ class MockClient:
52
54
  self.responded = True
53
55
  self.file_path = path
54
56
 
57
+ def response_stream(self, content_type=None, headers=None, cookies=None):
58
+ self.streaming = True
59
+ self.stream_content_type = content_type
60
+ self.stream_headers = headers
61
+ self.stream_cookies = cookies
62
+ return True
63
+
64
+ def send_event(self, data=None, event=None, event_id=None, retry=None):
65
+ if not hasattr(self, '_events'):
66
+ self._events = []
67
+ self._events.append({
68
+ 'data': data, 'event': event,
69
+ 'event_id': event_id, 'retry': retry})
70
+ return getattr(self, '_connected', True)
71
+
72
+ def send_chunk(self, data):
73
+ if not hasattr(self, '_chunks'):
74
+ self._chunks = []
75
+ self._chunks.append(data)
76
+ return getattr(self, '_connected', True)
77
+
78
+ def response_stream_end(self):
79
+ self.stream_ended = True
80
+
55
81
 
56
82
  class TestDispatcherSyncRoutes(unittest.TestCase):
57
83
 
@@ -413,5 +439,118 @@ class TestDispatcherRoutePriority(unittest.TestCase):
413
439
  self.assertEqual(len(d._pending), 0)
414
440
 
415
441
 
442
+ class TestDispatcherSSE(unittest.TestCase):
443
+
444
+ def _make_dispatcher(self):
445
+ pool = WorkerPool(DummyWorker, routes=['/api/**'])
446
+ d = Dispatcher.__new__(Dispatcher)
447
+ d._sync_routes = []
448
+ d._static_routes = {}
449
+ d._pools = [pool]
450
+ d._pending = {}
451
+ d._max_pending = 1000
452
+ d._next_request_id = 0
453
+ d._response_queue = mp.Queue()
454
+ d._log_is_tty = False
455
+ d.on_log = lambda *_: None
456
+ return d, pool
457
+
458
+ def test_sse_open(self):
459
+ d, pool = self._make_dispatcher()
460
+ client = MockClient('GET', '/api/events')
461
+ pending = _PendingRequest(client, pool)
462
+ pending.worker_id = 0
463
+ d._pending[1] = pending
464
+ d._process_response(
465
+ (MSG_SSE_OPEN, 1, 'text/event-stream', None, None))
466
+ self.assertTrue(client.streaming)
467
+ self.assertEqual(client.stream_content_type, 'text/event-stream')
468
+ self.assertTrue(pending.streaming)
469
+ # still in pending
470
+ self.assertIn(1, d._pending)
471
+
472
+ def test_sse_send_event(self):
473
+ d, pool = self._make_dispatcher()
474
+ client = MockClient('GET', '/api/events')
475
+ pending = _PendingRequest(client, pool)
476
+ pending.streaming = True
477
+ d._pending[1] = pending
478
+ d._process_response(
479
+ (MSG_SSE_EVENT, 1, {'count': 5}, 'update', '3', None))
480
+ self.assertEqual(len(client._events), 1)
481
+ self.assertEqual(client._events[0]['data'], {'count': 5})
482
+ self.assertEqual(client._events[0]['event'], 'update')
483
+ self.assertEqual(client._events[0]['event_id'], '3')
484
+
485
+ def test_sse_send_chunk(self):
486
+ d, pool = self._make_dispatcher()
487
+ client = MockClient('GET', '/api/stream')
488
+ pending = _PendingRequest(client, pool)
489
+ pending.streaming = True
490
+ d._pending[1] = pending
491
+ # send_chunk: event/event_id/retry are all None
492
+ d._process_response(
493
+ (MSG_SSE_EVENT, 1, b'raw data', None, None, None))
494
+ self.assertEqual(len(client._chunks), 1)
495
+ self.assertEqual(client._chunks[0], b'raw data')
496
+
497
+ def test_sse_close(self):
498
+ d, pool = self._make_dispatcher()
499
+ client = MockClient('GET', '/api/events')
500
+ pending = _PendingRequest(client, pool)
501
+ pending.streaming = True
502
+ d._pending[1] = pending
503
+ d._process_response((MSG_SSE_CLOSE, 1))
504
+ self.assertTrue(client.stream_ended)
505
+ # removed from pending
506
+ self.assertNotIn(1, d._pending)
507
+
508
+ def test_sse_client_disconnect(self):
509
+ d, pool = self._make_dispatcher()
510
+ pool.start(d._response_queue)
511
+ client = MockClient('GET', '/api/events')
512
+ client._connected = False # simulate disconnected client
513
+ pending = _PendingRequest(client, pool)
514
+ pending.streaming = True
515
+ pending.worker_id = 0
516
+ d._pending[1] = pending
517
+ d._process_response(
518
+ (MSG_SSE_EVENT, 1, {'data': 'test'}, 'ping', None, None))
519
+ # removed from pending
520
+ self.assertNotIn(1, d._pending)
521
+ # CTL_DISCONNECT sent to worker's control queue
522
+ msg = pool._control_queues[0].get(timeout=1)
523
+ self.assertEqual(msg, (CTL_DISCONNECT, 1))
524
+ pool.shutdown(timeout=2)
525
+
526
+ def test_streaming_excluded_from_timeout(self):
527
+ d, pool = self._make_dispatcher()
528
+ client = MockClient('GET', '/api/events')
529
+ pending = _PendingRequest(client, pool)
530
+ pending.streaming = True
531
+ pending.timestamp = 0 # very old
532
+ d._pending[1] = pending
533
+ d._expire_pending()
534
+ # streaming request should NOT be expired
535
+ self.assertIn(1, d._pending)
536
+
537
+ def test_non_streaming_expires(self):
538
+ d, pool = self._make_dispatcher()
539
+ client = MockClient('GET', '/api/test')
540
+ pending = _PendingRequest(client, pool)
541
+ pending.timestamp = 0 # very old
542
+ d._pending[1] = pending
543
+ d._expire_pending()
544
+ # non-streaming should be expired
545
+ self.assertNotIn(1, d._pending)
546
+
547
+ def test_sse_event_ignored_after_close(self):
548
+ d, pool = self._make_dispatcher()
549
+ # no pending request with id 99
550
+ d._process_response(
551
+ (MSG_SSE_EVENT, 99, {'data': 'test'}, None, None, None))
552
+ # should not raise, just ignore
553
+
554
+
416
555
  if __name__ == '__main__':
417
556
  unittest.main()