Skip to content

Add native WebSocket traffic tracking#171

Open
giobass wants to merge 62 commits into
pmusolino:masterfrom
giobass:feature/websocket-support
Open

Add native WebSocket traffic tracking#171
giobass wants to merge 62 commits into
pmusolino:masterfrom
giobass:feature/websocket-support

Conversation

@giobass

@giobass giobass commented Jul 3, 2026

Copy link
Copy Markdown

Goal

Add native WebSocket traffic interception to Wormholy alongside the existing HTTP request tracking, without requiring a third-party WebSocket library or changes to how the host app sends and receives messages.

Wormholy can capture URLSessionWebSocketTask traffic from both completion-handler and async/await APIs via method swizzling, and surface it in a new WebSockets tab next to Requests.

How to test

The WormholyDemo app includes a WebSocket console connected to Postman's public WebSocket echo service.

  1. Run WormholyDemo on a simulator or device.
  2. In the WebSocket section, keep or enter an echo URL and tap Connect.
  3. Send text or the sample JSON message, toggle test headers, and close or reopen the connection.
  4. Shake the device, open the WebSockets tab, and inspect the captured connection, headers, messages, body details, and export.

Automated coverage runs through WormholyDemoTests:

xcodebuild test -scheme WormholyDemo

The Postman-backed integration tests are gated behind WORMHOLY_RUN_NETWORK_TESTS=1, so standard CI remains deterministic. When enabled, they cover real completion-handler and async/await send/receive paths, plus delegate lifecycle forwarding and capture.

What changed

  • Core interception: WebSocket task factory methods are swizzled in Swift; Objective-C swizzles concrete runtime task classes for send and receive, and provides a lightweight URLSession delegate proxy for real lifecycle callbacks. cancel(with:reason:) is intercepted on the Swift side.
  • WebSocketInterceptor and WebSocketModel: installation and opt-in tracking logic, MainActor-isolated observable connection state, request/response headers, protocols, messages, close details, and errors.
  • Recorder batching: all WebSocket events flow through one serial recorder queue and are coalesced into ordered MainActor flushes. When configured, webSocketMessageLimit also bounds pending message payloads before they reach the UI.
  • Storage: WebSocket connections have their own published collection. Wormholy.limit applies the same configured number independently to HTTP requests and WebSocket connections.
  • UI: RequestsView now supports Requests and WebSockets modes, with connection rows, detail inspection, message body views, and export.
  • Public API: Wormholy.setWebSocketEnabled(_:) enables native WebSocket tracking independently of HTTP tracking. Wormholy.webSocketMessageLimit optionally retains the newest N messages per connection.
  • CustomHTTPProtocol: WebSocket upgrade requests are excluded from HTTP URLProtocol handling.
  • Demo app: adds WebSocketEchoClient and a console for end-to-end manual validation.
  • Tests and README: model, storage, interceptor, factory, recorder, delegate lifecycle, and HTTP/WebSocket boundary coverage; public behavior and limitations are documented.

Versioning

This is a backwards-compatible feature addition (new opt-in API, no breaking changes), so per semver it'd map to a 2.5.0 release. Leaving the actual Wormholy.podspec bump to you, since that's been handled as a separate release commit after merge in this repo's history.

Possible next step

The demo app (WormholyDemo) is still UIKit + Storyboard. A follow-up worth considering is migrating it to SwiftUI, which would simplify wiring future demo features like this WebSocket console without touching Main.storyboard.

Limitations

  • Enable WebSocket tracking before creating WebSocket tasks. Existing tasks cannot be attached retroactively.
  • Real didOpen and didClose events require a delegate-backed session created while WebSocket tracking is enabled. URLSession.shared still captures messages but does not provide these lifecycle callbacks.
  • Handshake response headers are shown only when Foundation exposes them.
  • External integration tests require WORMHOLY_RUN_NETWORK_TESTS=1.

@giobass
giobass force-pushed the feature/websocket-support branch from d5de2a0 to bc8ec6e Compare July 3, 2026 18:26
@giobass
giobass marked this pull request as ready for review July 3, 2026 18:30
giobass and others added 3 commits July 3, 2026 20:39
Forced .thinMaterial/.visible fought the system nav bar transition, causing a large-title glitch on push. RequestDetailView never set this and pushes cleanly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
WebSocketInterceptor attached a model to every task regardless of host, so ignoredHosts only ever worked for HTTP. Reuse CustomHTTPProtocol's suffix-match check before recording. Docs updated to reflect the shared behavior.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Two new tests: an ignored host skips model attachment, a non-ignored one still attaches.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@giobass

giobass commented Jul 4, 2026

Copy link
Copy Markdown
Author

Note: I've also started drafting a migration of the demo app to SwiftUI in my fork's feature/swiftui-migration branch. I'll open a proper PR for it once this one is merged.

@pmusolino
pmusolino self-requested a review July 8, 2026 16:53

@pmusolino pmusolino left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Nice work @giobass , this is a big and useful feature! I think the swizzling approach makes sense for Wormholy because it keeps the host app code unchanged, but before merge I would tighten the memory story for long-running sockets and add one real send/receive capture test. After that, this feels much safer to maintain.

Small note on file headers: I am migrating the project to a simpler standard header, so for new files I prefer using:

// Copyright (c) 2026 Wormholy contributors
// SPDX-License-Identifier: MIT

No big issue with the current Xcode-created header, just asking so new files follow the direction of the repo (which I will document in README).

Comment thread Sources/WormholySwift/Models/WebSocketModel.swift
Comment thread Sources/WormholySwift/Models/WebSocketModel.swift Outdated
Comment thread README.md Outdated
Comment thread Sources/WormholySwift/Storage.swift
Comment thread Sources/WormholySwift/Storage.swift
Comment thread Sources/WormholySwift/Models/WebSocketModel.swift
Comment thread Sources/WormholySwift/Models/WebSocketModel.swift Outdated
Comment thread Sources/WormholySwift/Models/WebSocketModel.swift Outdated
Comment thread Tests/WormholyTests/XCTestCase+WaitForMainQueue.swift Outdated
Comment thread WormholyDemo/Networking/WebSocketEchoClient.swift Outdated
Comment thread Sources/WormholySwift/Utils/WebSocketModelBeautifier.swift Outdated
Comment thread Sources/WormholySwift/UI/BodyDetailView.swift
Comment thread Sources/WormholySwift/UI/WebSocketDetailView.swift Outdated
Comment thread Tests/WormholyTests/WebSocketModelTests.swift
@pmusolino

Copy link
Copy Markdown
Owner

Thanks again for all the updates. I did another pass over the PR and I think there are still a few issues that should be fixed before the merge. Once those are addressed, I will be happy to take another look.

@giobass
giobass force-pushed the feature/websocket-support branch from a47f992 to 4d5d9fe Compare July 20, 2026 02:13
@giobass

giobass commented Jul 20, 2026

Copy link
Copy Markdown
Author

@pmusolino I have addressed the other review comments. The remaining open points are the ones related to the delegate-proxy installation race condition and its test coverage.
Before making further changes, I would appreciate your guidance on the design you would prefer for addressing those two points. I want to make sure the final implementation and test coverage align with your expectations and avoid unnecessary iterations on this PR. Thanks.

Comment thread Sources/WormholySwift/WebSocketInterceptor.swift Outdated
Comment thread Sources/WormholySwift/WebSocketMessageRingBuffer.swift
Comment thread Tests/WormholyTests/WebSocketModelTests.swift
Comment thread Sources/WormholySwift/Utils/WebSocketModelBeautifier.swift Outdated
Comment thread Tests/WormholyTests/WebSocketModelTests.swift
@pmusolino

Copy link
Copy Markdown
Owner

Thanks again for the updates! I did another pass and the previous feedback is addressed. The delegate-proxy race is still the same open issue; I also found one more edge case in the message limit and a performance issue from the new Base64 preview. I think we should fix these before merge.

@giobass

giobass commented Jul 21, 2026

Copy link
Copy Markdown
Author

@pmusolino I addressed the remaining race-condition feedback and added the focused concurrent first-install coverage discussed above.
I hope these changes match your guidance. I would appreciate a final look before merging.

@giobass
giobass requested a review from pmusolino July 21, 2026 01:53
WebSocketConfiguration.messageLimit = nil
return
}
WebSocketConfiguration.messageLimit = NSNumber(value: Int(clamping: newValue.uint64Value))

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

I think one value range still escapes this normalization. For example NSNumber(value: 1e20).uint64Value wraps before Int(clamping:), so it stores a lower value instead of Int.max as docs say. Can we compare with Int.max before converting to UInt64, then truncate only values inside the range?

XCTAssertEqual(model.messages.map(\.text), ["first", "second"])
}

func testOutOfRangeWebSocketMessageLimitIsClampedToIntMax() {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Current case covers UInt64.max, which converts correctly. Can we add one finite Double bigger than UInt64.max, for example 1e20? This will catch wrapping before the Int clamp.

BOOL isClassMethod,
void (^ _Nonnull storeOriginal)(IMP _Nonnull originalImpl));

#if DEBUG

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

This declaration disappears without DEBUG, but the Objective-C test file is still compiled in Release. Can we keep this in private test support for every test configuration, or guard the complete test with the same condition?

WHReplaceMethod(selector, newImpl, affectedClass, isClassMethod, storeOriginal, nil);
}

#if DEBUG

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Same for implementation: in Release this symbol is removed while test call remains. Can we make declaration, implementation and test use one consistent build condition?

atomic_store_explicit(&whExampleForwardedCallCount, 0, memory_order_relaxed);

dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{
WormholyReplaceMethodStoringOriginalForTesting(@selector(makeSession),

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

This call is unconditional while helper declaration and implementation are DEBUG only, so a Release test build cannot compile or link this file. Please make the hook available to test target in all configurations, or wrap the complete test in the same guard.

@pmusolino

Copy link
Copy Markdown
Owner

Hopefully the last ones :D

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