Skip to content

Web socket api refactor - #3704

Open
jmthomas wants to merge 9 commits into
mainfrom
web-socket-api-refactor
Open

Web socket api refactor#3704
jmthomas wants to merge 9 commits into
mainfrom
web-socket-api-refactor

Conversation

@jmthomas

@jmthomas jmthomas commented Aug 11, 2026

Copy link
Copy Markdown
Member

The progression of commits is important. I first created more unit tests to verify existing functionality. Brought coverage to 100%. Then reconciled the Ruby and Python behavior. Then refactored to remove duplication in source. Then refactored to remove duplication in test.

jmthomas and others added 3 commits August 11, 2026 13:00
The two clients had drifted in ten ways. Fixes three real bugs: python
disconnect leaked the socket when the courtesy unsubscribe failed, add and
remove defaulted to the import-time OPENC3_SCOPE so an api built with an
explicit scope streamed from DEFAULT, and read_all crashed on len(None) when
the socket closed before the empty-batch end marker.

Python also now raises on missing auth env vars instead of returning None,
normalizes empty and malformed frames to None, honors Script Runner stop via a
sys.modules lookup that avoids an import cycle, accepts int nanoseconds as well
as datetimes, tolerates a disconnect frame with no reason, and gains the
missing SystemEventsWebSocketApi. Ruby normalizes generate_url to the ws/wss
schemes, which python's websockets library requires.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Collapse the timeout/auth keyword block repeated across 13 constructors into
DEFAULT_OPTIONS plus **options forwarding, the 7 near-identical history_count
event classes into a HistoryCountIdentifier mixin driven by a CHANNEL constant,
and the two generate_url bodies into a shared cable_url helper. Extract
write_command, parse_message/check_protocol_frame and stream_action so the
command framing, frame parsing and action building each live in one place.

Behavior is unchanged except that a typo'd option name now raises instead of
being silently swallowed by **options, restoring what explicit keyword
arguments used to catch. Adds direct tests for write, which is still public API
but no longer has an internal caller now that write_action frames directly.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread openc3/spec/script/web_socket_api_spec.rb Fixed
Comment thread openc3/python/test/script/test_web_socket_api.py Fixed
@codecov

codecov Bot commented Aug 11, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 79.60%. Comparing base (aa1af27) to head (f272696).

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3704      +/-   ##
==========================================
+ Coverage   79.35%   79.60%   +0.25%     
==========================================
  Files         885      885              
  Lines       65365    65364       -1     
  Branches     2591     2591              
==========================================
+ Hits        51870    52033     +163     
+ Misses      12826    12658     -168     
- Partials      669      673       +4     
Flag Coverage Δ
frontend 63.69% <ø> (-0.05%) ⬇️
python 81.87% <ø> (+0.33%) ⬆️
ruby-api 82.57% <ø> (ø)
ruby-backend 84.43% <100.00%> (+0.35%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR refactors the WebSocket API implementations (Ruby and Python) to reduce duplication, normalize URL generation, and tighten protocol/error-handling behavior while expanding unit test coverage to validate the new behaviors.

Changes:

  • Centralized common WebSocket options handling, protocol frame checks, and command-frame writing logic.
  • Normalized cable URL generation from environment variables (including ws/wss scheme normalization and OPENC3_DEVEL hostname behavior).
  • Added extensive Ruby RSpec and Python unittest coverage for connect/disconnect, subscribe handshake, cooperative stop, identifier construction, and streaming read-all behavior.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

File Description
openc3/spec/script/web_socket_api_spec.rb Adds broad RSpec coverage using a fake stream and env helper to validate the refactored Ruby WebSocket API behavior.
openc3/python/test/script/test_web_socket_api.py Adds comprehensive Python unit tests mirroring Ruby coverage for the refactored Python WebSocket API.
openc3/python/openc3/script/web_socket_api.py Refactors Python WebSocket API: shared option validation, protocol handling, URL builder, streaming helpers, and safer disconnect behavior.
openc3/lib/openc3/script/web_socket_api.rb Refactors Ruby WebSocket API: shared option validation, cable URL builder, protocol parsing/checking, streaming helper, and improved internal factoring.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread openc3/lib/openc3/script/web_socket_api.rb
Malformed frames and mid-stream closes were swallowed as end-of-stream,
turning protocol errors into a silent exit 1 in cli_script_monitor and a
nil-deref in the documented consumer loops. Let them surface instead.

read_all now distinguishes a bounded query, where a missing end marker
means a truncated result and must raise, from a realtime query, which is
never sent one and simply keeps what it collected. Ruby's end_time is
optional to match Python, and disconnect clears @subscribed even when the
courtesy unsubscribe fails on a half-closed socket.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
@jmthomas

Copy link
Copy Markdown
Member Author

Behavior changes (both Ruby and Python):

  • read_all distinguishes bounded historical queries from realtime streams. Bounded queries raise if the socket closes before the end marker. Realtime returns the batches collected so far. Before both would just raise.
  • Disconnect always closes the underlying socket and clears subscription state, even if the courtesy unsubscribe fails.

Python changes:

  • Honors Script Runner stop requests while processing protocol frames. Previously Stop would not interrupt a blocking read()
  • disconnect no longer leaks the socket
  • _generate_auth raise when authentication environment variables are missing matching Ruby
  • add/remove respect the api's scope. For example an api built with scope='OTHER' still streamed from DEFAULT
  • Accepts either datetimes or integer nanosecond timestamps.
  • Provides the previously missing SystemEventsWebSocketApi.

Ruby changes:

  • Ruby-generated URLs now normalize http/https to ws/wss (similar to Python).
  • read_all's end_time parameter is now optional matching Python
  • Malformed JSON now surfaces as a parsing error instead of silently ending

@jmthomas
jmthomas requested a review from mcosgriff August 12, 2026 16:07
Comment thread openc3/python/test/script/test_web_socket_api.py Outdated
Comment on lines +78 to +88
# Options every websocket api accepts, and their defaults. Subclasses
# forward **options rather than restating these.
DEFAULT_OPTIONS = {
"write_timeout": 10.0,
"read_timeout": 10.0,
"connect_timeout": 5.0,
"authentication": None,
}

def __init__(self, url, scope=OPENC3_SCOPE, **options):
"""Create the WebsocketApi object

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

just a note that this is a potential breaking change if people called it with positional args

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I went through the usage throughout our code and I don't see any use of positional args. I will call this out in the release notes.

@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
4.9% Duplication on New Code (required ≤ 3%)

See analysis details on SonarQube Cloud

@jmthomas
jmthomas requested review from EmilyRagan and ryan-pratt and a balanced review from Copilot August 18, 2026 13:24

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

Suppressed comments (2)

openc3/python/openc3/script/web_socket_api.py:525

  • None does not uniquely mean the socket closed: WebSocketClientStream.read() also returns None whenever its read timeout expires (10 seconds by default). Consequently a quiet bounded query raises “closed” even while connected, and the new realtime mode exits after 10 seconds despite being documented as endless. Distinguish timeout from EOF (or explicitly configure and handle the stream timeout) before deciding whether to raise or break.
                if batch is None:
                    # A bounded query must receive its end marker; a truncated
                    # result returned as if complete is worse than an error. A
                    # realtime query never gets one, so a close is an ordinary
                    # way for it to end.
                    if end_time is not None:
                        raise RuntimeError("WebSocket closed before end marker")
                    break

openc3/lib/openc3/script/web_socket_api.rb:468

  • Making end_time optional introduces a realtime mode, but self.new still uses the 10-second default read_timeout. On a quiet realtime stream, api.read therefore raises Timeout::Error after 10 seconds, so this cannot stream endlessly as documented; the method-level timeout also cannot control that read. Configure the socket timeout for this mode and handle its expiration as the collection timeout rather than an error.
    def self.read_all(items: nil, packets: nil, start_time: nil, end_time: nil, scope: nil, timeout: nil)
      read_all_start_time = Time.now
      data = []
      self.new do |api|
        api.add(items: items, packets: packets, start_time: start_time, end_time: end_time, scope: scope)
        while true
          batch = api.read

"authentication": None,
}

def __init__(self, url, scope=OPENC3_SCOPE, **options):
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.

4 participants