Skip to content
C.W.K.
Stream
Lesson 04 of 06 · published

A Swift Engine: Loopback, Serve Twins, and bind(2)

~18 min · engine-at-home, server, bind, network-framework, tailscale-serve

Level 0Bundle Opener
0 XP0/81 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"A Swift engine behind a Serve twin must not listen through Network.framework."

When the Engine Is Swift

Most family engines are Python services. A few serve HTTP from Swift, among them the terminal daemon and the prose editor's sync engine on the office Mac that its iPad and iPhone replicas talk to. The kit gives them one shared listener, and it deliberately does very little: one bounded HTTP/1.1 request per connection, Connection: close, loopback by default, and a body limit every engine must state because no default could be right for both a 16 KiB command and a 64 MiB document.

The Respawn That Could Not Bind

The sync engine first listened with NWListener on 127.0.0.1:7200, and Tailscale Serve published its HTTP twin on port 7200 of the tailnet addresses. It worked until the engine restarted. From then on, every launchd respawn failed with NWError 48, Address already in use, and both Serve routes answered 502. Nothing held 127.0.0.1:7200. The Serve daemon held 7200 on the tailnet's IPv4 and IPv6 addresses, and it had only ever worked because the engine happened to start before the twin existed. A reboot with Tailscale up first orders it the same broken way.

Every NWListener spelling failed: a required local endpoint of IPv4 loopback, the same as a string, a loopback interface type, local-only acceptance, endpoint reuse on and off. A plain bind(2) of 127.0.0.1:7200 beside the Serve twin succeeded, with or without SO_REUSEADDR. The reproduction needs no tailnet: hold the port on [::1] with IPV6_V6ONLY, then ask both APIs for 127.0.0.1. Run again for this lesson with Swift 6.3.3 on macOS 26, the result was the same. So the kit listener now sits on a BSD socket and serves each accepted descriptor with dispatch sources, and the family rule is simply: bind(2) behind a Serve twin.

Two earlier NWListener findings are worth knowing even so. cancel() returns before the port is released, so an in-process restart on the same port needs to wait for the .cancelled state. And a reply written in parts over NWConnection must use one content context for every part, or the body waits behind a head that never completes until the client times out.

What the Listener Refuses, and In What Order

The order is pinned: parse the head, then check Host and Origin, then apply the body rules. A request with a foreign Host and a 4 GiB length gets 403, not 413, so an untrusted caller learns nothing about which routes accept bodies. Through the HTTP twin the Host header stays the Mac's tailnet name with the port, so the engine's allowed hosts must name it. Only GET and POST, no Transfer-Encoding, and no Expect header, which means curl -d @file with a body over 1 MiB is refused because curl sends Expect: 100-continue. After replying, the listener shuts down its write side and keeps reading briefly: closing a socket with unread input sends a reset that can destroy a 413 before the client reads it.

A handler that does synchronous disk work, like a sync engine scanning its data folder, goes through the listener's blocking handler door on a dispatch queue, not the async one, so it never occupies a thread of Swift's cooperative pool for the length of a scan.

Code

Reproduce the Serve-twin collision without a tailnet, and listen with bind(2)·swift
import Darwin
import Foundation
import Network

/// Listen on 127.0.0.1:port with a BSD socket: SO_REUSEADDR for our own TIME_WAITs, FD_CLOEXEC for children.
func listenLoopbackIPv4(port: UInt16) -> Result<Int32, POSIXError> {
    let fd = socket(AF_INET, SOCK_STREAM, 0)
    guard fd >= 0 else { return .failure(POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)) }
    var yes: Int32 = 1
    setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &yes, socklen_t(MemoryLayout<Int32>.size))
    _ = fcntl(fd, F_SETFD, FD_CLOEXEC)
    var address = sockaddr_in()
    address.sin_len = UInt8(MemoryLayout<sockaddr_in>.size)
    address.sin_family = sa_family_t(AF_INET)
    address.sin_port = port.bigEndian
    address.sin_addr.s_addr = inet_addr("127.0.0.1")
    let bound = withUnsafePointer(to: &address) {
        $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { bind(fd, $0, socklen_t(MemoryLayout<sockaddr_in>.size)) }
    }
    guard bound == 0, listen(fd, 64) == 0 else {
        let failure = POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)
        close(fd)
        return .failure(failure)
    }
    return .success(fd)
}

/// The Serve twin's shape without a tailnet: something listening on [::1]:port.
func holdIPv6Loopback(port: UInt16) -> Int32 {
    let fd = socket(AF_INET6, SOCK_STREAM, 0)
    var yes: Int32 = 1
    setsockopt(fd, IPPROTO_IPV6, IPV6_V6ONLY, &yes, socklen_t(MemoryLayout<Int32>.size))
    var address = sockaddr_in6()
    address.sin6_len = UInt8(MemoryLayout<sockaddr_in6>.size)
    address.sin6_family = sa_family_t(AF_INET6)
    address.sin6_port = port.bigEndian
    address.sin6_addr = in6addr_loopback
    _ = withUnsafePointer(to: &address) {
        $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { bind(fd, $0, socklen_t(MemoryLayout<sockaddr_in6>.size)) }
    }
    listen(fd, 8)
    return fd
}

// Reproduction: hold [::1]:port, then ask both APIs for 127.0.0.1:port.
let port: UInt16 = 47_311
let holder = holdIPv6Loopback(port: port)

let parameters = NWParameters.tcp
parameters.requiredLocalEndpoint = .hostPort(host: .ipv4(.loopback), port: NWEndpoint.Port(rawValue: port)!)
let listener = try NWListener(using: parameters)
let done = DispatchSemaphore(value: 0)
listener.stateUpdateHandler = { state in
    switch state {
    case .ready: print("NWListener 127.0.0.1:\(port): ready"); done.signal()
    case .failed(let error): print("NWListener 127.0.0.1:\(port): failed \(error)"); done.signal()
    default: break
    }
}
listener.newConnectionHandler = { $0.cancel() }
listener.start(queue: .global())
_ = done.wait(timeout: .now() + 3)
listener.cancel()

switch listenLoopbackIPv4(port: port) {
case .success(let fd): print("bind(2) 127.0.0.1:\(port): listening"); close(fd)
case .failure(let error): print("bind(2) 127.0.0.1:\(port): \(error.code)")
}
close(holder)
Output on macOS 26 with Swift 6.3.3·text
NWListener 127.0.0.1:47311: failed POSIXErrorCode(rawValue: 48): Address already in use
bind(2) 127.0.0.1:47311: listening

External links

Exercise

Run the reproduction and record both lines. Then change the holder to bind a non-loopback IPv4 address of your Mac on the same port (find one with ipconfig getifaddr en0) and run it again. Finally, extend listenLoopbackIPv4 into a tiny server that accepts one connection, reads until the blank line after the headers, replies HTTP/1.1 200 OK with Content-Length and Connection: close, and test it with curl -v while the holder still owns [::1] on that port.
Hint
After listen, a blocking accept on a background thread is enough for a one-connection test. Write the reply, call shutdown(fd, SHUT_WR), read until the client closes, and only then close.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.