API: Server-Side Event Stream - #24
Conversation
📝 WalkthroughWalkthroughAdds a threaded SSE broadcaster that receives telemetry messages, formats and buffers them, and exposes them through a new Flask ChangesSSE telemetry streaming
Estimated code review effort: 3 (Moderate) | ~20 minutes Sequence Diagram(s)sequenceDiagram
participant Telemetry
participant Stream
participant SSEClient
Telemetry->>Stream: Forward telemetry message
Stream->>Stream: Format, buffer, and signal update
Stream-->>SSEClient: Yield SSE event
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
Simple test app (vibecoded): Results snapshot: |
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@app.py`:
- Around line 176-179: Update the SSE Response returned around
stream_obj.sse_broadcast_callback() to include Cache-Control and
X-Accel-Buffering headers that disable caching and proxy buffering, while
preserving the existing text/event-stream mimetype and streaming behavior.
In `@stream.py`:
- Line 21: Update the Stream implementation to maintain a bounded per-client
queue, such as collections.deque, instead of storing only the latest event in
self.data. Enqueue each published message for the relevant connected client and
have client consumption drain its own queue, preserving every event until the
configured capacity is reached.
- Around line 15-59: Refactor Stream to use a single threading.Condition instead
of signal_broadcast_event and stream_mutex, publishing updates by incrementing
sequence_number and notifying waiting clients without immediately clearing a
signal. In sse_put_callback, serialize the response with json.dumps so the SSE
payload is valid JSON. In sse_broadcast_callback, copy the pending data and
update last_seq_num while holding the condition, then release it before each
yield; wait in a loop for a newer sequence or termination so slow clients cannot
block publishers or lose notifications. Add the json import.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: 266c0873-c1d7-46c2-9cf4-0db9f76361a2
📒 Files selected for processing (2)
app.pystream.py
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
…mutexes and events Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
|
The old polling route still sees similar performance drops, but now it happens even without SSE clients connected. We'll have to revisit this as this is not acceptable for the production version of the API. Converting to a draft. |
|
This may be due to the increased rate on the emulator compared to actual hardware use. Every incoming message is published. We should rate limit this to ~30 Hz. The client-server contract should include a condition that the client must be able to handle requests at this rate. The rate limiter should consider using delays to hold the publication until the time delta has expired. This way, it will not react to sent events while it is waiting for that delay to expire. |
… the rate of request broadcasts
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@stream.py`:
- Around line 47-82: Fix the rate limiter in the stream generator by using
time.monotonic(), correcting the condition so sleeping occurs only when the
elapsed interval is shorter than RATE_LIMITER_PERIOD, and updating last_time
after each broadcast. Ensure lock release and reacquisition around the sleep are
protected with try/finally so generator closure cannot leave stream_cond in an
invalid state; preserve termination handling and message delivery.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: 6f54e8b1-1233-4806-bd75-72027b782fd7
📒 Files selected for processing (2)
app.pystream.py
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
| "messageType": messageType, | ||
| "payload": msg | ||
| } | ||
| self.data = f"data: {json.dumps(response)}\n\n".encode("utf-8") |
There was a problem hiding this comment.
json.dumps() running 500Hz is prob not the best performance wise, which is why you were having performance issues.
Probably move it from callback into sse_broadcast_callback s.t. json.dump serializes at broadcast rate instead at 500Hz. I assume within the while not self.terminate.
There was a problem hiding this comment.
Also, self.data is single variable, which ig is fine for SDEC, but, at least for LQD-DAQ, I would have to use a queue (deque) to prevent last-message overriding, so it's rather a question if you're willing to lose some data.
Optional, just a note.
There was a problem hiding this comment.
it's not 500 hz, it's like 60-ish for the serial path and 25-ish for LoRa. Given that your rate is upwards of 500 hz though this should def be considered. I'm definitely willing to drop packets since my priority is just making sure the data I'm feeding is the most up to date and I have very little latency. The deque sounds great for you if you're trying to preserve every packet, just make sure you're not flooding the client at more than 60 hz. I personally recommend 30 for the clients' sake, since their refresh rate won't go very high. The one concern I have about this rate limiting is on the rolling pressure chart.
| yield data_to_yield | ||
|
|
||
| # Wait for new messages to be put | ||
| while not self.terminate: |
There was a problem hiding this comment.
Just random thing, but may a try...finally clause in case you want to use finally: as a way to detect a browser tab closing or to decrement client counter if so choose to consider multiple connections.
Inno, just a neat thing instead of waiting time outs for disconnection.
There was a problem hiding this comment.
The API itself doesn't track client state atm. This is handled behind some layers of abstraction by Flask & Werkzeug. The background processing layer has no idea how many clients are in the foreground. We may change this eventually if we need to throttle too many client connections, but I'm planning for ~4 simultaneous clients right now.
| response = { | ||
| "origin": ORIGIN_STRING, | ||
| "version": STREAM_VERSION_STRING, | ||
| "timestamp": timestamp, | ||
| "messageType": messageType, | ||
| "payload": msg | ||
| } |
There was a problem hiding this comment.
JSON structure
|
Will tag this to be pulled in alongside the state estims and telemetry epic! Ready to ship with the parent. |

Closes: #23
Adds a method to broadcast received messages to all connected clients rather than requiring polling.
Preliminary performance testing does indicate slightly worse performance than the polling implementation. We should determine the cause of this. This performance hit does not affect the API as long as no SSE clients are connected though.
Library Side: SunDevilRocketry/SDECv2#76
Merge is dependent on the state-estims-telemetry integration branch.