Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions GhosttyTabs.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@
8C4BBF2DEF6DF93F395A9EE7 /* TerminalControllerSocketSecurityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 491751CE2321474474F27DCF /* TerminalControllerSocketSecurityTests.swift */; };
2BB56A710BB1FC50367E5BCF /* TabManagerSessionSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10D684CFFB8CDEF89CE2D9E1 /* TabManagerSessionSnapshotTests.swift */; };
C1A2B3C4D5E6F70800000001 /* CmuxConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A2B3C4D5E6F70800000002 /* CmuxConfigTests.swift */; };
C1A2B3C4D5E6F70800000006 /* ServeWebPortStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A2B3C4D5E6F70800000005 /* ServeWebPortStoreTests.swift */; };
/* End PBXBuildFile section */

/* Begin PBXCopyFilesBuildPhase section */
Expand Down Expand Up @@ -334,6 +335,7 @@
491751CE2321474474F27DCF /* TerminalControllerSocketSecurityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalControllerSocketSecurityTests.swift; sourceTree = "<group>"; };
10D684CFFB8CDEF89CE2D9E1 /* TabManagerSessionSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabManagerSessionSnapshotTests.swift; sourceTree = "<group>"; };
C1A2B3C4D5E6F70800000002 /* CmuxConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxConfigTests.swift; sourceTree = "<group>"; };
C1A2B3C4D5E6F70800000005 /* ServeWebPortStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServeWebPortStoreTests.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */

/* Begin PBXFrameworksBuildPhase section */
Expand Down Expand Up @@ -615,6 +617,7 @@
491751CE2321474474F27DCF /* TerminalControllerSocketSecurityTests.swift */,
10D684CFFB8CDEF89CE2D9E1 /* TabManagerSessionSnapshotTests.swift */,
C1A2B3C4D5E6F70800000002 /* CmuxConfigTests.swift */,
C1A2B3C4D5E6F70800000005 /* ServeWebPortStoreTests.swift */,
);
path = cmuxTests;
sourceTree = "<group>";
Expand Down Expand Up @@ -899,6 +902,7 @@
8C4BBF2DEF6DF93F395A9EE7 /* TerminalControllerSocketSecurityTests.swift in Sources */,
2BB56A710BB1FC50367E5BCF /* TabManagerSessionSnapshotTests.swift in Sources */,
C1A2B3C4D5E6F70800000001 /* CmuxConfigTests.swift in Sources */,
C1A2B3C4D5E6F70800000006 /* ServeWebPortStoreTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down
84 changes: 78 additions & 6 deletions Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -967,16 +967,16 @@ final class VSCodeServeWebController {

let process = Process()
process.executableURL = launchConfiguration.executableURL
// TODO(#21): --port 0 means VS Code gets an OS-assigned ephemeral port each restart, so
// the browser must re-navigate to the new URL. To stabilise: either use a fixed loopback
// port, or persist the port assigned by VS Code (from ServeWebOutputCollector) under
// vscodeServerDataDir and reuse it here. The --server-data-dir and persistent
// connection-token already fix Settings Sync / OAuth auth; the URL change is UX-only.
// #21: reuse the port VS Code assigned on a previous run so the embedded browser
// keeps the same URL across restarts. ServeWebPortStore returns the persisted port
// only when it is still bindable, otherwise "0" (OS-assigned) — so a now-occupied
// port falls back gracefully instead of failing the launch. The --server-data-dir
// and persistent connection-token already fix Settings Sync / OAuth auth.
process.arguments = launchConfiguration.argumentsPrefix + [
"serve-web",
"--accept-server-license-terms",
"--host", "127.0.0.1",
"--port", "0",
"--port", ServeWebPortStore.portArgument(persistedIn: Self.vscodeServerDataDir),
"--server-data-dir", Self.vscodeServerDataDir?.path ?? NSTemporaryDirectory(),
"--connection-token-file", connectionTokenFileURL.path,
]
Expand Down Expand Up @@ -1075,6 +1075,11 @@ final class VSCodeServeWebController {
return nil
}

// #21: remember the assigned port so the next launch can request it again.
if let assignedPort = serveWebURL.port {
ServeWebPortStore.persist(port: assignedPort, in: Self.vscodeServerDataDir)
}

return (process, serveWebURL)
}

Expand Down Expand Up @@ -1216,6 +1221,73 @@ final class ServeWebOutputCollector {
}
}

/// Persists the VS Code serve-web port across restarts (#21) so the embedded browser
/// keeps the same URL. The port `code serve-web` assigns is written under the serve-web
/// data dir and reused on the next launch — but only when it is still bindable, so a
/// now-occupied port falls back to an OS-assigned one instead of failing the launch.
enum ServeWebPortStore {
static let fileName = "serve-web-port"

/// The `--port` argument for `code serve-web`: the persisted port when it is valid and
/// currently free, otherwise "0" (let the OS assign one). `isPortAvailable` is injectable
/// for testing; it defaults to a real loopback bind probe.
static func portArgument(
persistedIn directory: URL?,
isPortAvailable: (Int) -> Bool = ServeWebPortStore.isPortAvailable
) -> String {
guard let url = portFileURL(in: directory),
let data = try? Data(contentsOf: url),
let raw = String(data: data, encoding: .utf8),
let port = parsePort(raw),
isPortAvailable(port) else {
return "0"
}
return String(port)
}

/// Records the port VS Code assigned so the next launch can request it again. No-op for
/// out-of-range ports or when the data dir is unavailable.
static func persist(port: Int, in directory: URL?) {
guard isValidPort(port), let url = portFileURL(in: directory) else { return }
let dir = url.deletingLastPathComponent()
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
try? Data(String(port).utf8).write(to: url, options: .atomic)
}

/// Parses a stored port string, returning nil for malformed or out-of-range values.
static func parsePort(_ raw: String) -> Int? {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard let port = Int(trimmed), isValidPort(port) else { return nil }
return port
}

/// True when a TCP socket can bind 127.0.0.1:port right now.
static func isPortAvailable(_ port: Int) -> Bool {
guard isValidPort(port) else { return false }
let fd = socket(AF_INET, SOCK_STREAM, 0)
guard fd >= 0 else { return false }
defer { close(fd) }
var reuse: Int32 = 1
_ = setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &reuse, socklen_t(MemoryLayout<Int32>.size))
var addr = sockaddr_in()
addr.sin_family = sa_family_t(AF_INET)
addr.sin_port = in_port_t(UInt16(port)).bigEndian
addr.sin_addr.s_addr = inet_addr("127.0.0.1")
let bound = withUnsafePointer(to: &addr) { pointer in
pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPointer in
bind(fd, sockaddrPointer, socklen_t(MemoryLayout<sockaddr_in>.size))
}
}
return bound == 0
}

private static func isValidPort(_ port: Int) -> Bool { (1...65535).contains(port) }

private static func portFileURL(in directory: URL?) -> URL? {
directory?.appendingPathComponent(fileName, isDirectory: false)
}
}

enum WorkspaceShortcutMapper {
/// Maps numbered workspace shortcuts to a zero-based workspace index.
/// 1...8 target fixed indices; 9 always targets the last workspace.
Expand Down
114 changes: 114 additions & 0 deletions cmuxTests/ServeWebPortStoreTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import XCTest
import Foundation
import Darwin

#if canImport(Programa_DEV)
@testable import Programa_DEV
#elseif canImport(Programa)
@testable import Programa
#endif

/// Tests for #21: persisting and reusing the VS Code serve-web port so the embedded
/// browser keeps the same URL across restarts, while falling back to an OS-assigned
/// port when the persisted one is no longer free.
final class ServeWebPortStoreTests: XCTestCase {
private func makeTempDir() -> URL {
let dir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
.appendingPathComponent("serve-web-port-test-\(UUID().uuidString)", isDirectory: true)
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
return dir
}

func testPortArgumentIsZeroWhenNoFile() {
let dir = makeTempDir()
XCTAssertEqual(
ServeWebPortStore.portArgument(persistedIn: dir, isPortAvailable: { _ in true }),
"0"
)
}

func testPersistThenReuseRoundTrips() {
let dir = makeTempDir()
ServeWebPortStore.persist(port: 50_321, in: dir)
XCTAssertEqual(
ServeWebPortStore.portArgument(persistedIn: dir, isPortAvailable: { _ in true }),
"50321"
)
}

func testPersistedPortIgnoredWhenUnavailable() {
let dir = makeTempDir()
ServeWebPortStore.persist(port: 50_321, in: dir)
XCTAssertEqual(
ServeWebPortStore.portArgument(persistedIn: dir, isPortAvailable: { _ in false }),
"0",
"An occupied persisted port must fall back to OS-assigned"
)
}

func testNilDirectoryIsZero() {
XCTAssertEqual(
ServeWebPortStore.portArgument(persistedIn: nil, isPortAvailable: { _ in true }),
"0"
)
}

func testPersistRejectsOutOfRangePorts() {
let dir = makeTempDir()
ServeWebPortStore.persist(port: 0, in: dir)
ServeWebPortStore.persist(port: 70_000, in: dir)
// Nothing valid was written, so the OS-assigned fallback still applies.
XCTAssertEqual(
ServeWebPortStore.portArgument(persistedIn: dir, isPortAvailable: { _ in true }),
"0"
)
}

func testParsePort() {
XCTAssertEqual(ServeWebPortStore.parsePort(" 8080 \n"), 8080)
XCTAssertEqual(ServeWebPortStore.parsePort("1"), 1)
XCTAssertEqual(ServeWebPortStore.parsePort("65535"), 65535)
XCTAssertNil(ServeWebPortStore.parsePort(""))
XCTAssertNil(ServeWebPortStore.parsePort("notaport"))
XCTAssertNil(ServeWebPortStore.parsePort("0"))
XCTAssertNil(ServeWebPortStore.parsePort("65536"))
XCTAssertNil(ServeWebPortStore.parsePort("-1"))
}

func testIsPortAvailableDetectsOccupiedPort() throws {
// Bind+listen on an OS-assigned loopback port, then assert the store reports it busy.
let listenFd = socket(AF_INET, SOCK_STREAM, 0)
try XCTSkipIf(listenFd < 0, "could not create probe socket")
defer { close(listenFd) }

var reuse: Int32 = 1
_ = setsockopt(listenFd, SOL_SOCKET, SO_REUSEADDR, &reuse, socklen_t(MemoryLayout<Int32>.size))

var addr = sockaddr_in()
addr.sin_family = sa_family_t(AF_INET)
addr.sin_port = 0 // OS-assigned
addr.sin_addr.s_addr = inet_addr("127.0.0.1")
let bound = withUnsafePointer(to: &addr) { pointer in
pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPointer in
Darwin.bind(listenFd, sockaddrPointer, socklen_t(MemoryLayout<sockaddr_in>.size))
}
}
try XCTSkipIf(bound != 0, "could not bind probe socket")
try XCTSkipIf(listen(listenFd, 1) != 0, "could not listen on probe socket")

var boundAddr = sockaddr_in()
var len = socklen_t(MemoryLayout<sockaddr_in>.size)
let got = withUnsafeMutablePointer(to: &boundAddr) { pointer in
pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPointer in
getsockname(listenFd, sockaddrPointer, &len)
}
}
try XCTSkipIf(got != 0, "could not read probe port")
let port = Int(UInt16(bigEndian: boundAddr.sin_port))

XCTAssertFalse(
ServeWebPortStore.isPortAvailable(port),
"A port held by a live listener must report as unavailable"
)
}
}
Loading