Skip to content

Switch to Waitress for production server and improve event handling - #9

Merged
Rishabh-Bajpai merged 8 commits into
mainfrom
development
May 31, 2026
Merged

Switch to Waitress for production server and improve event handling#9
Rishabh-Bajpai merged 8 commits into
mainfrom
development

Conversation

@Rishabh-Bajpai

@Rishabh-Bajpai Rishabh-Bajpai commented May 31, 2026

Copy link
Copy Markdown
Collaborator

Summary by CodeRabbit

Release Notes

  • New Features

    • Improved streaming connection stability through heartbeat forwarding
    • Enhanced message reconciliation with authoritative refresh behavior and inline part-removal handling
    • systemd integration for production service management
    • Granular event type classification for improved message handling
    • Configurable Waitress thread and channel timeout settings via environment variables
  • Bug Fixes

    • Fixed event envelope parsing for improved compatibility
    • Improved error handling for backend unavailability during streaming
    • Fixed direct-shape message.updated event handling during inline reconciliation
    • Fixed duplicate message insertion during send reconciliation
    • Fixed startup health-check variable expansion under set -u
  • Chores

    • Updated server configuration and dependencies
    • Enhanced startup/shutdown scripts for service orchestration
    • Configured frontend origin handling for backend communication
    • Restricted Flask debug server binding to loopback by default with explicit opt-ins for broader binding/reload

    Production mode uses waitress (8 threads, 300s channel timeout) instead of Flask's dev server
    Falls back to app.run(debug=True) if FLASK_DEBUG=1 is set (for development)

backend/requirements.txt

    Added waitress==3.0.2

Why this fixes the error

Werkzeug's dev server (with debug=True) wraps the WSGI app in DebuggedApplication, which buffers entire streaming responses in memory to catch and display errors. For SSE streams that stay open indefinitely, this caused a Content-Length to be calculated, which then mismatched the actual body when the upstream connection dropped. Waitress does not buffer streaming responses — it properly uses Transfer-Encoding: chunked for stream_with_context generators, so no Content-Length is ever set on SSE responses.
@coderabbitai

coderabbitai Bot commented May 31, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@Copilot, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 50 minutes and 57 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: a1ac32ce-b048-4f87-9d8f-456247bcf0b3

📥 Commits

Reviewing files that changed from the base of the PR and between 7d18a72 and ad171b7.

📒 Files selected for processing (5)
  • backend/run.py
  • frontend/src/App.tsx
  • frontend/src/utils/streamUtils.ts
  • scripts/install-autostart-ubuntu.sh
  • scripts/start-app.sh
📝 Walkthrough

Walkthrough

This PR implements GlobalEvent envelope parsing across backend and frontend SSE streams, transitions the backend to production-grade Waitress server with proper timeouts, and adds systemd integration to deployment scripts for flexible service management.

Changes

GlobalEvent Envelope Parsing and Inline SSE Reconciliation

Layer / File(s) Summary
Backend SSE envelope handling and heartbeat passthrough
backend/app/routes/helpers.py, backend/app/routes/messages.py
Session matching now unwraps GlobalEvent-style envelopes to search the inner payload. SSE streams forward upstream heartbeat lines (: prefixed) to keep connections alive; explicit Connection: keep-alive headers are removed from both project-scoped and global stream responses.
Frontend stream utilities: envelope parsing and event extraction
frontend/src/utils/streamUtils.ts
New extraction utilities (extractPayloadFromDataLines, extractPartFromEvent, extractMessageFromEvent, extractTodosFromEvent, extractDiffFromEvent, extractSessionStatusFromEvent) unwrap GlobalEvent envelopes and parse specific event types. StreamEventClassification expanded with flags for part deltas, session updates, todo updates, diff updates, and message deletions. parseApprovalFromStreamData, parseQuestionFromStreamData, classifyStreamEvent, and extractMessagePartText updated to handle envelope unwrapping.
Frontend App: message reconciliation and inline SSE updates
frontend/src/App.tsx
loadMessages now merges server responses into client state, preserving existing message parts and in-progress edits instead of wholesale replacement. SSE onmessage handler derives eventLines from JSON wrapper and applies granular inline updates driven by event classification: text deltas, full part replacements, message upserts, todo/diff/session updates. handleSendMessage removes local echo messages, applies server-returned state, and schedules delayed silent reconciliation.

Production Server Configuration and Proxy Setup

Layer / File(s) Summary
Waitress server setup with conditional debug/production modes
backend/requirements.txt, backend/run.py
Added waitress==3.0.2 dependency. Backend startup branches based on FLASK_DEBUG environment variable: uses Flask debug mode when set, otherwise serves via Waitress with 16 threads and 300s channel timeout. OSError handler logs binding failures to stderr and exits with code 1.
Environment variables and Vite proxy streaming configuration
scripts/run-backend-service.sh, frontend/vite.config.ts
Backend startup injects FRONTEND_ORIGINS environment variable (defaulting to http://localhost:5173). Vite dev proxy adds proxyTimeout and timeout settings; new configure hook keeps connections alive for /stream requests, removes content-length from streamed responses, and returns JSON 502 error when backend is unavailable and headers have not yet been sent.

Systemd Deployment Integration

Layer / File(s) Summary
Service startup with systemd detection and conditional port management
scripts/start-app.sh
New systemd_active() helper detects systemd-managed services. Helpers is_running(), pick_port(), and wait_for_opencode() refactored for systemd compatibility. Startup restructured into phases: resolve frontend port first (from systemd files or via Python socket bind discovery), derive CORS origins, conditionally start OpenCode (reading systemd port or launching manually with health check), conditionally start backend (passing resolved OpenCode base URL and frontend origins), and finally start frontend dev server. PID/port/URL written to .runtime files in manual mode; logs redirected to $LOG_DIR/*.log.
Service shutdown with systemd and fallback process termination
scripts/stop-app.sh
New systemd_active() helper and stop_service() refactoring: when a service unit is systemd-managed, instructs user to stop via systemctl; otherwise attempts PID-file kill (SIGTERM then SIGKILL), falls back to pattern-based kill via stop_by_pattern(), then port-based kill via stop_by_port(). Runtime .runtime files cleaned up at script end.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐇 A rabbit hops through streams of data,
Envelopes unwrapped with care,
Waitress serves with grace and style,
Systemd keeps the services fair,
Reconciliation blooms online,
No more reloads—just smooth refine! 🎀

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 3.57% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately captures the two primary aspects of the changeset: switching to Waitress for production server and improving event handling across backend and frontend.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch development

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 10

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
backend/app/routes/helpers.py (1)

1001-1020: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Unwrap the envelope before all event-specific parsing, not just session matching.

_event_matches_session() now understands payload.payload, but _parse_permission_event() and _parse_question_event() still inspect the top-level type/properties. Wrapped permission.* and question.* frames will therefore match the session and still never update the persisted pending state, so approvals/questions disappear after refresh or reconnect.

🤖 Prompt for 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.

In `@backend/app/routes/helpers.py` around lines 1001 - 1020,
_event_matches_session currently unwraps the GlobalEvent envelope into local
variables parsed→inner→target but _parse_permission_event and
_parse_question_event still read the original top-level payload/type, so wrapped
frames (payload.payload) match session but are not parsed into persisted pending
state; fix by unwrapping the envelope before any event-specific parsing (move
the parsed→inner→target logic to the common entry point or have
_parse_permission_event and _parse_question_event accept/use the
already-unwrapped target), and update those functions to inspect
target.get("type") and target.get("properties") (or accept the unwrapped dict
argument) so permission.* and question.* frames nested under payload.payload are
parsed and persisted correctly.
🤖 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 `@backend/run.py`:
- Around line 14-15: When FLASK_DEBUG is enabled the current app.run call
exposes the debugger on 0.0.0.0; change the run logic so that when
os.environ.get("FLASK_DEBUG") is truthy you run app.run(host="127.0.0.1",
port=BACKEND_PORT, debug=True, threaded=True) to bind debug to loopback, and
only allow host="0.0.0.0" when an explicit opt-in (e.g. a separate env var like
FLASK_DEBUG_BIND_ALL) is set; update the conditional around app.run (the call to
app.run in backend/run.py) to check FLASK_DEBUG_BIND_ALL before using 0.0.0.0
while keeping BACKEND_PORT and threaded=True unchanged.
- Around line 18-23: The serve(...) call currently hard-codes threads=16 and
channel_timeout=300; update it to read THREADS and CHANNEL_TIMEOUT (or similarly
named) environment variables with sensible defaults (e.g., 16 and 300), parse
them to integers, validate/fallback on invalid values, and pass those variables
into serve(...) instead of the literals; modify where serve(app, host="0.0.0.0",
port=BACKEND_PORT, threads=16, channel_timeout=300, ...) is invoked to use the
parsed env values so operators can configure concurrency and idle timeouts at
runtime.

In `@frontend/src/App.tsx`:
- Around line 2497-2531: The branch keyed by classification.hasMessageUpdate
currently reads msgPayload.properties.info directly (via
extractPayloadFromDataLines) and misses normalized direct-shape message
payloads; replace that direct access with the stream helper that normalizes both
properties.info and direct properties (e.g., call the normalized message
extractor provided in the stream helpers before using msgPayload), then use the
returned normalized message object when calling setMessages and when
building/updating ChatMessage (preserve existing checks for id/role/text/parts
and the existing update/new-message logic inside setMessages).
- Around line 1727-1755: The merge currently preserves client-side
parts/messages when the server returns fewer parts or omits a message (in the
loop over freshMessages and the follow-up loop over current), which prevents
server-side deletions (message.removed/compaction) from taking effect; change
the logic in the freshMessages merge to always treat the server (fm) as
authoritative: remove the branches that reuse existing.parts when fm.parts is
missing or shorter and always push fm (or { ...fm, parts: fm.parts }) so server
parts replace client parts; likewise remove the second loop’s behavior that
re-adds cm when freshById lacks it — do not push current messages that aren’t
present in freshById/seenIds so omitted messages are dropped, ensuring
message.removed and compaction work as intended (refer to variables
freshMessages, current, existing, mergedParts, seenIds, and freshById to locate
changes).
- Around line 2538-2555: The identifier setInvocationHeaders used in the App.tsx
update block is not declared; add a matching state setter (or use the correct
prop) so the updater works. Specifically, declare a React state hook like const
[invocationHeaders, setInvocationHeaders] = useState<YourType>(initialValue) in
the same component (and import useState from React if missing), or replace
setInvocationHeaders with the actual setter name if the state already exists
under a different identifier; ensure the state type matches the object shape
used in the updater so TypeScript compiles.

In `@frontend/src/utils/streamUtils.ts`:
- Around line 252-257: The event type "message.part.removed" is being lumped
with part updates so extractPartFromEvent() never yields data for removals;
update the classification logic (the branch handling eventType in
streamUtils.ts) to mark removals separately (e.g., set a distinct flag like
result.hasPartRemoved) and then update extractPartFromEvent() or the caller that
processes these flags to return or act on a removal payload (such as the part
id) so the UI removes the part instead of no-op; also apply the same change to
the other occurrence noted (lines ~356-381) so removed parts are handled
consistently.

In `@scripts/start-app.sh`:
- Around line 125-128: The script advertises FRONTEND_BASE_URL as
"http://127.0.0.1:${FRONTEND_PORT}" which differs from the default
FRONTEND_ORIGINS/FRONTEND_ALLOWED_HOSTS ("localhost"), causing CORS mismatches;
change FRONTEND_BASE_URL to use the same default origin as FRONTEND_ORIGINS
(e.g., "http://localhost:${FRONTEND_PORT}") or derive FRONTEND_BASE_URL from
FRONTEND_ORIGINS so the printed URL and the allowed origin match; update any use
of FRONTEND_BASE_URL accordingly and ensure OPENCODE_CORS_ORIGINS remains
derived from FRONTEND_ORIGINS.
- Around line 109-116: In wait_for_opencode(), the URL is being constructed
using ${port} before port is assigned which breaks under set -u; change the
local declarations so port is assigned first (e.g., local port="$1") and then
build url using that value (e.g., local
url="http://127.0.0.1:${port}/global/health"), or use a safe default like local
port="${1:-<default>}" before composing url; update the wait_for_opencode
function accordingly and ensure curl uses the constructed url; also note the
localhost vs 127.0.0.1 origin mismatch still needs separate CORS/code-path
validation.

In `@scripts/stop-app.sh`:
- Around line 45-60: The stop_by_port function currently falls back to the
hard-coded default port when the .port file is missing; change it so the
function requires a recorded runtime port file and does not use the default port
for termination: in stop_by_port, if the port_file does not exist, log a warning
and return without attempting to discover/kill by default_port, and apply the
same behavior to the analogous logic around lines 93-96 (the other port-based
stopper) so no process is killed unless a valid .port file is present; keep the
existing kill logic when a port is read from the file.
- Around line 68-70: The script currently returns early when systemd_active
"$systemd_unit" is true but later unconditionally removes runtime metadata
(.port/.url) — preserve metadata for services you did not stop. Change
stop_service so that cleanup of .port/.url files only runs when this script
actually stopped the service (e.g., set and check a local flag like
"stopped_by_script" or move the metadata removal into the branch that performs
the stop), keep the systemd_active check and early return in place, and
reference the existing systemd_active and stop_service logic to ensure
.port/.url files are not deleted for systemd-managed services.

---

Outside diff comments:
In `@backend/app/routes/helpers.py`:
- Around line 1001-1020: _event_matches_session currently unwraps the
GlobalEvent envelope into local variables parsed→inner→target but
_parse_permission_event and _parse_question_event still read the original
top-level payload/type, so wrapped frames (payload.payload) match session but
are not parsed into persisted pending state; fix by unwrapping the envelope
before any event-specific parsing (move the parsed→inner→target logic to the
common entry point or have _parse_permission_event and _parse_question_event
accept/use the already-unwrapped target), and update those functions to inspect
target.get("type") and target.get("properties") (or accept the unwrapped dict
argument) so permission.* and question.* frames nested under payload.payload are
parsed and persisted correctly.
🪄 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: CHILL

Plan: Pro

Run ID: 80af3a87-8d48-4514-8dfa-443fbf46fe16

📥 Commits

Reviewing files that changed from the base of the PR and between 46db8bb and 7d18a72.

📒 Files selected for processing (10)
  • backend/app/routes/helpers.py
  • backend/app/routes/messages.py
  • backend/requirements.txt
  • backend/run.py
  • frontend/src/App.tsx
  • frontend/src/utils/streamUtils.ts
  • frontend/vite.config.ts
  • scripts/run-backend-service.sh
  • scripts/start-app.sh
  • scripts/stop-app.sh
📜 Review details
🧰 Additional context used
🪛 ast-grep (0.42.3)
backend/run.py

[warning] 14-14: Running flask app with host 0.0.0.0 could expose the server publicly.
Context: app.run(host="0.0.0.0", port=BACKEND_PORT, debug=True, threaded=True)
Note: [CWE-668]: Exposure of Resource to Wrong Sphere [OWASP A01:2021]: Broken Access Control [REFERENCES]
https://owasp.org/Top10/A01_2021-Broken_Access_Control

(avoid_app_run_with_bad_host-python)

🪛 Ruff (0.15.14)
backend/run.py

[error] 15-15: Possible binding to all interfaces

(S104)


[error] 20-20: Possible binding to all interfaces

(S104)

🪛 Shellcheck (0.11.0)
scripts/start-app.sh

[warning] 110-110: This assignment is used again in this 'local', but won't have taken effect. Use two 'local's.

(SC2318)

Comment thread backend/run.py Outdated
Comment thread backend/run.py Outdated
Comment thread frontend/src/App.tsx Outdated
Comment thread frontend/src/App.tsx Outdated
Comment thread frontend/src/App.tsx Outdated
Comment thread frontend/src/utils/streamUtils.ts Outdated
Comment thread scripts/start-app.sh
Comment thread scripts/start-app.sh
Comment thread scripts/stop-app.sh
Comment on lines +45 to 60
stop_by_port() {
local name="$1" port_file="$2" default_port="$3"
local port
if [[ -f "$port_file" ]]; then
port="$(cat "$port_file")"
else
port="$default_port"
fi

# kill only the process listening on the specific port
local pid
pid="$(cat "$pid_file")"
if [[ -z "$pid" ]]; then
rm -f "$pid_file"
return 1
fi

if ! kill -0 "$pid" >/dev/null 2>&1; then
rm -f "$pid_file"
return 1
pid="$(ss -tlnp "sport = :${port}" 2>/dev/null | grep -oP 'pid=\K[0-9]+' | head -1)"
if [[ -n "$pid" ]]; then
kill "$pid" 2>/dev/null || true
sleep 0.5
kill -0 "$pid" 2>/dev/null && kill -9 "$pid" 2>/dev/null || true
fi

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Require recorded runtime state before doing port-based termination.

If the .port file is missing, this falls back to killing whatever is listening on the hard-coded default port. On a fresh machine, after cleanup, or when only a systemd unit exists, that can terminate an unrelated process on 5173/38473/40961.

Suggested fix
 stop_by_port() {
   local name="$1" port_file="$2" default_port="$3"
   local port
-  if [[ -f "$port_file" ]]; then
-    port="$(cat "$port_file")"
-  else
-    port="$default_port"
-  fi
+  [[ -f "$port_file" ]] || return 1
+  port="$(cat "$port_file")"
   # kill only the process listening on the specific port
   local pid
   pid="$(ss -tlnp "sport = :${port}" 2>/dev/null | grep -oP 'pid=\K[0-9]+' | head -1)"
   if [[ -n "$pid" ]]; then
     kill "$pid" 2>/dev/null || true
     sleep 0.5
     kill -0 "$pid" 2>/dev/null && kill -9 "$pid" 2>/dev/null || true
+    return 0
   fi
+  return 1
 }
@@
-  if ! $stopped; then
-    stop_by_port "$name" "$port_file" "$default_port"
-    stopped=true
+  if ! $stopped && stop_by_port "$name" "$port_file" "$default_port"; then
+    stopped=true
   fi

Also applies to: 93-96

🤖 Prompt for 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.

In `@scripts/stop-app.sh` around lines 45 - 60, The stop_by_port function
currently falls back to the hard-coded default port when the .port file is
missing; change it so the function requires a recorded runtime port file and
does not use the default port for termination: in stop_by_port, if the port_file
does not exist, log a warning and return without attempting to discover/kill by
default_port, and apply the same behavior to the analogous logic around lines
93-96 (the other port-based stopper) so no process is killed unless a valid
.port file is present; keep the existing kill logic when a port is read from the
file.

Comment thread scripts/stop-app.sh
Comment on lines +68 to +70
if systemd_active "$systemd_unit"; then
echo "$name: managed by systemd ($systemd_unit) — use 'sudo systemctl stop $systemd_unit'"
return 0

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't delete runtime metadata for services you didn't stop.

When a service is active under systemd, stop_service returns early and leaves it running, but Line 110 still removes every .port/.url file. scripts/start-app.sh relies on those files to recover active systemd ports, so the next startup can fall back to the wrong defaults and wire the stack to the wrong endpoints.

Also applies to: 110-110

🤖 Prompt for 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.

In `@scripts/stop-app.sh` around lines 68 - 70, The script currently returns early
when systemd_active "$systemd_unit" is true but later unconditionally removes
runtime metadata (.port/.url) — preserve metadata for services you did not stop.
Change stop_service so that cleanup of .port/.url files only runs when this
script actually stopped the service (e.g., set and check a local flag like
"stopped_by_script" or move the metadata removal into the branch that performs
the stop), keep the systemd_active check and early return in place, and
reference the existing systemd_active and stop_service logic to ensure
.port/.url files are not deleted for systemd-managed services.

@Rishabh-Bajpai

Copy link
Copy Markdown
Collaborator Author

@copilot Please perform a PR review, look at the comments, and fix all issues.

Copilot AI commented May 31, 2026

Copy link
Copy Markdown
Contributor

@copilot Please perform a PR review, look at the comments, and fix all issues.

Fixed the outstanding review issues and checked the newer development changes too in ad171b7.

Validated with:

  • npm --prefix frontend run build
  • pytest backend/tests -q
  • bash -n scripts/start-app.sh

Code review passed on the final branch state. CodeQL passed on the earlier rerun after the security fix; the last full validation rerun hit a timeout.

@Rishabh-Bajpai
Rishabh-Bajpai merged commit 100687a into main May 31, 2026
1 check passed
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.

2 participants