Skip to content

Hand a connection to a handler after its response, as CONNECT needs - #254

Open
lhoward wants to merge 2 commits into
swhitty:mainfrom
PADL:http-tunnel-upstream
Open

lhoward wants to merge 2 commits into
swhitty:mainfrom
PADL:http-tunnel-upstream

Conversation

@lhoward

@lhoward lhoward commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor

A server answering CONNECT (RFC 9110 §9.3.6), or switching to a protocol other than WebSocket, needs the connection itself once the response head has been written. HTTPResponse.Payload offers a body or a WebSocket handler, and there is no other way to reach the socket.

This adds a payload for that:

  • HTTPResponse(tunnel:) creates a response whose payload is .tunnel(any HTTPTunnelHandler).
  • Once the head is written, HTTPTunnelHandler is called with the bytes the client sends and the AsyncSocket, for writing and for socket options. The bytes are the connection's buffered stream, so anything the client sent along with its request comes first.
  • The connection closes when the handler returns.
  • The head carries Date but neither Content-Length nor Transfer-Encoding, since a 2xx answer to CONNECT must not have them.

Our use is the ATDECC Proxy Protocol (IEEE 1722.1-2021 Annex C), which tunnels ATDECC messages over an HTTP CONNECT connection on the same port as its WebSocket variant. We'd rather serve both from one FlyingFox server than parse HTTP ourselves.

Adding a case to Payload breaks exhaustive switches over it outside the package, so this probably wants a minor version bump. HTTPEncoderTests had one, which is updated.

The handler is given the AsyncSocket itself, a wider surface than WSHandler exposes. That's deliberate: a tunnel needs socket options (send buffer, keepalive, TCP_USER_TIMEOUT) and write timeouts that only the socket can provide. If you'd rather wrap it in a narrower type, I'm happy to do that.

Tests: a new HTTPConnectionTests case checks that bytes sent with a CONNECT request reach the handler first, and that what the handler writes reaches the client. On Linux (Swift 6.4) the full suite runs 314 tests. The only failures are HTTPDecoderTests' standardizedPath and naughtyPath_IsParsed, which fail the same way on main without this change.

Happy to rename or reshape it if you'd prefer a different API.

🤖 Generated with Claude Code

https://claude.ai/code/session_01SGwDLWKAC1NJDijG6Ss6YH

@codecov

codecov Bot commented Nov 9, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.78%. Comparing base (e8332f9) to head (bfe01b2).

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #254      +/-   ##
==========================================
+ Coverage   93.76%   93.78%   +0.02%     
==========================================
  Files          72       72              
  Lines        3866     3880      +14     
==========================================
+ Hits         3625     3639      +14     
  Misses        241      241              

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

lhoward added a commit to PADL/ATDECCSwift that referenced this pull request Nov 9, 2026
The ATDECC proxy serves HTTP CONNECT (IEEE 1722.1-2021 Clause C.1)
through FlyingFox's HTTPResponse(tunnel:), which is not yet in a
FlyingFox release (swhitty/FlyingFox#254). The CONNECT transport is now
behind a package trait, HTTPTunnel, off by default, so that ATDECCProxy,
and the packages depending on it, build against released FlyingFox.

With the trait off, the APS serves WebSocket on /ws only, and refuses
CONNECT with 501 Not Implemented and Allow: GET: the server supports
CONNECT for no resource (RFC 9110 Section 15.6.2), which 405, meaning
not for this resource, would misstate; Allow points at the method it
does support. With the trait on, CONNECT is served as before.

The loopback tests open their sessions with CONNECT when the trait is
on and with a WebSocket upgrade when it is off, so the transport-neutral
behaviour (the connection limit, two APCs at once, entity IDs refused
when advertised on the LAN, link down, a connection that has not sent
its request using no slot) is tested either way. Tests of CONNECT
itself (the Annex C session, APPDUs sent with the request, a bad
subtype, and the write timeout of an APC that stops reading) compile
only with the trait; without it, a test checks the 501 refusal. The
CONNECT session's head is checked for its status line and the absence
of length fields, as FlyingFox's main branch adds a Date field.

Verified with swift build and swift test on Linux (Swift 6.4): trait off
against FlyingFox 0.27.1 as released, 50 proxy tests, 10 runs clean;
trait on against swhitty/FlyingFox#254's branch (http-tunnel-upstream)
through swift package edit, 53 proxy tests, 10 runs clean; the whole
suite passes both ways.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SGwDLWKAC1NJDijG6Ss6YH
lhoward added a commit to PADL/ATDECCSwift that referenced this pull request Nov 9, 2026
…n and listener fixes

Review of the FlyingFox-based server.

- The APS's own host is allowed as a WebSocket Origin by host alone: a
  page at http://10.10.32.8 opening ws://10.10.32.8:17221 has a Host
  field with the port and an Origin without it, so the two never
  matched. Bracketed IPv6 literals ([fe80::1]:8080) keep their brackets
  when the port is removed.
- An APC whose WebSocket closes before the transfer starts is answered
  with its close status, as one closing later is.
- A CONNECT tunnel shuts its socket down only while it has it: the
  shutdowns of a write timeout, a stalled APC or the drain deadline go
  through a guard that is invalidated before the handler returns, so
  none can reach a descriptor FlyingFox has closed and the system has
  given to another socket.
- A listener's failures are logged whenever the error changes, not only
  the first, and a listener on the IPv6 wildcard that cannot bind for
  want of IPv6 listens on the IPv4 wildcard instead.
- With the HTTPTunnel trait, whose FlyingFox (main) bounds WebSocket
  frames, each frame is bounded to AppWebSocketMaximumMessageLength
  (webSocketMaxFrameSize), so an oversized one fails before it is read
  whole; without it, messages are still checked once assembled.
- Advertisements whose valid_time has passed are pruned from the entity
  ID table as frames are observed.

FlyingFox's listener still ends, cancelling the connections it serves,
when an accept fails (EMFILE, ECONNABORTED, ...); the server starts it
again. A fix, accepting again after such failures, is on
PADL/FlyingFox accept-transient-errors.

Verified with swift build and swift test on Linux (Swift 6.4): new tests
for the origin's host, an early close answered, pruning, and (with the
trait) an oversized frame ending the connection. Trait off, against
FlyingFox 0.27.1 as released: 53 proxy tests, 10 runs clean. Trait on,
against swhitty/FlyingFox#254's branch: 57 proxy tests, 10 runs clean.
The whole suite passes both ways.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SGwDLWKAC1NJDijG6Ss6YH
lhoward added a commit to PADL/ATDECCSwift that referenced this pull request Nov 9, 2026
AtdeccProxyServer.setAddresses(_:) changes where the proxy listens while
it runs: a FlyingFox listener starts at each address newly given, and
one at an address no longer given stops, closing its connections; the
others, and their APCs, are untouched. A device whose own addresses
change (a DHCP renewal, a global IPv6 address arriving after boot)
follows them this way, rather than starting the proxy again or polling.
Listeners are made with the server, at the configured addresses, and
start when it runs; an address that is not an IP literal is refused,
changing nothing.

Verified with swift build and swift test on Linux (Swift 6.4): a new
loopback test moves the listener from 127.0.0.1 to 127.0.0.2, the APC on
the old address losing its connection and one at the new address being
served. Trait off, against FlyingFox 0.27.1 as released: 54 proxy
tests, 10 runs clean. Trait on, against swhitty/FlyingFox#254's branch:
58 proxy tests, 10 runs clean. The whole suite passes both ways.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SGwDLWKAC1NJDijG6Ss6YH
lhoward added a commit to PADL/ATDECCSwift that referenced this pull request Nov 9, 2026
…evice needs

The proxy listens on the IPv6 wildcard, which accepts IPv4 too: one
FlyingFox HTTPServer, with no addresses in its configuration. A server
that cannot listen ends run() with an error, for its host to log; the
device runs on without the proxy.

Gone, as machinery for misbehaviour a single-purpose box does not see:
- listeners per address, setAddresses(_:), and the restart loop with
  its log-when-the-error-changes and IPv4 fallback;
- for CONNECT, keepalive, TCP_USER_TIMEOUT, the fixed send buffer, the
  per-write timeout, the guarded descriptor and the 2 s drain: the
  tunnel shuts its socket down when the connection closes or its APC
  stops reading, both before the handler returns;
- the queue of frames for the LAN: they are sent as the state machine
  produces them;
- in the entity-ID table, the set of live connections and the separate
  received and sent paths: one observe(_:sentBy:), and what an APC
  advertised is forgotten with its connection.

Kept: the Annex C state machine and messages; WebSocket on /ws with its
Origin check and close codes; CONNECT behind the HTTPTunnel trait (501
without it); the limit of 8 connections, as a slot given back however a
connection ends; the bounded output queue that ends the connection of an
APC that stops reading; the ADP-based entity-ID check; the frame bound
with the trait.

Appdu is encoded and decoded with swift-binary-parsing, as the ATDECC
PDUs are: init(parsing:) reads the version, message type, big-endian
payload_length, address and payload, refusing what it refused before,
and serialize(into:) writes them; the entity ID payload is a big-endian
UInt64 through the parser. AppduStreamDecoder parses whole APPDUs from
its buffer the same way.

ATDECCProxy's sources are 1320 lines, from 1617.

Verified with swift build and swift test on Linux (Swift 6.4): new tests
for an APPDU round trip through serialize and parse, and for a
connection whose APC stops reading being ended once its output is full;
the tests of address following and of the CONNECT write timeout are
gone with them. Trait off, against FlyingFox 0.27.1 as released: 55
proxy tests, 10 runs clean. Trait on, against swhitty/FlyingFox#254's
branch: 58 proxy tests, 10 runs clean. The whole suite passes both ways.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SGwDLWKAC1NJDijG6Ss6YH
lhoward added a commit to PADL/ATDECCSwift that referenced this pull request Nov 9, 2026
When the APS closed a WebSocket connection itself (CLOSED for an
AVTPDU outside Table C.7, Clause C.5.2.2.4, or no entity ID left to
assign), FlyingFox sent the close and closed the socket, but the task
reading the client's frames stayed suspended on it, and with it the
connection's slot, its state and its AF_PACKET port: with one slot, a
client that sent a bad subtype and stayed connected locked every later
one out with 503. The read is now cancelled once the connection's state
machine ends, whoever ended it, as FlyingFox's MessageFrameWSHandler
ends its reader. A client that stops reading has its connection ended
the same way: the full output ends the state machine, and the slot is
given back while FlyingFox's write waits on the socket.

run() logs the port it listens on once listening, rather than the
configured one, which is 0 when the kernel is to choose.

Verified with swift build and swift test on Linux (Swift 6.4): a new
test, with one slot, has a client send a bad subtype and stay connected,
and a second client served; it fails without this change. Trait off,
against FlyingFox 0.27.1 as released: 56 proxy tests, 10 runs clean.
Trait on, against swhitty/FlyingFox#254's branch: 59 proxy tests, 10
runs clean. The whole suite passes both ways.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SGwDLWKAC1NJDijG6Ss6YH

@swhitty swhitty 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.

Thanks for this — the tunnel abstraction is great. Handing the connection over once the response head is written is the right primitive, and I'd like to move the WebSocket handler onto it in the future (an upgrade is really a tunnel with a frame codec on top).

One change I'd like before merging: rather than passing the raw AsyncSocket to the handler, could we wrap it in a small type with a minimal interface? Something like:

public struct HTTPTunnel: Sendable {
    public var bytes: some AsyncBufferedSequence<UInt8>
    public func write(_ data: Data) async throws
    public func setValue<O: SocketOption>(_ value: O.Value, for option: O) throws
}

public protocol HTTPTunnelHandler: Sendable {
    func handleTunnel(_ tunnel: HTTPTunnel) async throws
}
  • The raw socket offers a second read path (socket.bytes, read(...)) that bypasses the connection's buffer, so bytes already buffered past the request can be skipped or reordered.
  • If a handler calls socket.close(), the fd can be reused by the next accepted connection before HTTPServer removes and closes its own (connections are keyed by fd), so the server could close the wrong client. The connection should own closing.
  • A client disconnect could simply end bytes, as HTTPRequestSequence does, rather than throw SocketError.disconnected into every handler.
  • A struct can grow later (e.g. shutdownWrite(), a stop signal, the originating request) without breaking conformers.

You keep socket options through setValue(_:for:) and backpressure through the awaited write.

🤖 Claude helped with this review


let connection = HTTPConnection(socket: s1)
// the client sends its first bytes before the response, as an APC may
try await s2.writeString("CONNECT / HTTP/1.1\r\nHost: example:17221\r\n\r\nping")

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.

CONNECT targets are authority-form (host:port, RFC 9110 §9.3.6), so could this send CONNECT example:17221 HTTP/1.1 rather than CONNECT /? The test only checks the method and the bytes, so it should still pass.

The decoder currently mangles authority-form targets into request.path (e.g. example.com:443 becomes "443"). I'll follow up separately with some parsing improvements there; until then a wildcard CONNECT route matches, and the authority can be read from request.target.

lhoward and others added 2 commits November 14, 2026 11:02
A server answering CONNECT (RFC 9110 Section 9.3.6), or switching to a
protocol other than WebSocket, needs the connection itself once the
response head is written. HTTPResponse.Payload had only a body or a
WebSocket handler, and nothing else could reach the socket.

HTTPResponse(tunnel:) gives the new payload, .tunnel, whose
HTTPTunnelHandler is called once the head is written with the bytes the
client sends (the connection's buffered stream, so bytes it sent with
its request come first) and the AsyncSocket, for writing and for socket
options. The connection is closed when the handler returns. The head
carries neither Content-Length nor Transfer-Encoding, as a 2xx answer to
CONNECT must not.

Adding a case to Payload breaks exhaustive switches over it outside the
package, as HTTPEncoderTests' had.

Tested with a new HTTPConnectionTests case: bytes sent with a CONNECT
request reach the handler first, and what it writes reaches the client.
HTTPDecoderTests' standardizedPath and naughtyPath_IsParsed fail on
Linux (Swift 6.4) with or without this change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SGwDLWKAC1NJDijG6Ss6YH
The handler is called once the head is written whatever its status; for
a non-2xx status, what to do with the connection is left to it, and it
may simply return. The protocol's summary also says "assumes the
connection" for a protocol other than CONNECT that has it after the
response.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SGwDLWKAC1NJDijG6Ss6YH
@lhoward
lhoward force-pushed the http-tunnel-upstream branch from d312b2a to bfe01b2 Compare November 14, 2026 00:03
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