Skip to content

Commit 07dd992

Browse files
authored
feat: persist VS Code serve-web port across restarts (#21) (#46)
serve-web ran with --port 0, so the OS assigned a fresh ephemeral port on every launch and the embedded browser had to re-navigate to a new URL each restart. Persist the assigned port under the serve-web data dir and request it again on the next launch. The port is only reused when it is still bindable on loopback; a now-occupied port falls back to --port 0 so it can never fail the launch. Completes #21 (the data dir and connection token were already persisted in d0fe6fa).
1 parent 6ec52ab commit 07dd992

3 files changed

Lines changed: 196 additions & 6 deletions

File tree

GhosttyTabs.xcodeproj/project.pbxproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@
136136
C1A2B3C4D5E6F70800000004 /* TerminalControllerShellStateDedupTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A2B3C4D5E6F70800000003 /* TerminalControllerShellStateDedupTests.swift */; };
137137
2BB56A710BB1FC50367E5BCF /* TabManagerSessionSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10D684CFFB8CDEF89CE2D9E1 /* TabManagerSessionSnapshotTests.swift */; };
138138
C1A2B3C4D5E6F70800000001 /* CmuxConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A2B3C4D5E6F70800000002 /* CmuxConfigTests.swift */; };
139+
C1A2B3C4D5E6F70800000006 /* ServeWebPortStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A2B3C4D5E6F70800000005 /* ServeWebPortStoreTests.swift */; };
139140
/* End PBXBuildFile section */
140141

141142
/* Begin PBXCopyFilesBuildPhase section */
@@ -336,6 +337,7 @@
336337
C1A2B3C4D5E6F70800000003 /* TerminalControllerShellStateDedupTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalControllerShellStateDedupTests.swift; sourceTree = "<group>"; };
337338
10D684CFFB8CDEF89CE2D9E1 /* TabManagerSessionSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabManagerSessionSnapshotTests.swift; sourceTree = "<group>"; };
338339
C1A2B3C4D5E6F70800000002 /* CmuxConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxConfigTests.swift; sourceTree = "<group>"; };
340+
C1A2B3C4D5E6F70800000005 /* ServeWebPortStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServeWebPortStoreTests.swift; sourceTree = "<group>"; };
339341
/* End PBXFileReference section */
340342

341343
/* Begin PBXFrameworksBuildPhase section */
@@ -618,6 +620,7 @@
618620
C1A2B3C4D5E6F70800000003 /* TerminalControllerShellStateDedupTests.swift */,
619621
10D684CFFB8CDEF89CE2D9E1 /* TabManagerSessionSnapshotTests.swift */,
620622
C1A2B3C4D5E6F70800000002 /* CmuxConfigTests.swift */,
623+
C1A2B3C4D5E6F70800000005 /* ServeWebPortStoreTests.swift */,
621624
);
622625
path = cmuxTests;
623626
sourceTree = "<group>";
@@ -903,6 +906,7 @@
903906
C1A2B3C4D5E6F70800000004 /* TerminalControllerShellStateDedupTests.swift in Sources */,
904907
2BB56A710BB1FC50367E5BCF /* TabManagerSessionSnapshotTests.swift in Sources */,
905908
C1A2B3C4D5E6F70800000001 /* CmuxConfigTests.swift in Sources */,
909+
C1A2B3C4D5E6F70800000006 /* ServeWebPortStoreTests.swift in Sources */,
906910
);
907911
runOnlyForDeploymentPostprocessing = 0;
908912
};

Sources/AppDelegate.swift

Lines changed: 78 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -967,16 +967,16 @@ final class VSCodeServeWebController {
967967

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

1078+
// #21: remember the assigned port so the next launch can request it again.
1079+
if let assignedPort = serveWebURL.port {
1080+
ServeWebPortStore.persist(port: assignedPort, in: Self.vscodeServerDataDir)
1081+
}
1082+
10781083
return (process, serveWebURL)
10791084
}
10801085

@@ -1216,6 +1221,73 @@ final class ServeWebOutputCollector {
12161221
}
12171222
}
12181223

1224+
/// Persists the VS Code serve-web port across restarts (#21) so the embedded browser
1225+
/// keeps the same URL. The port `code serve-web` assigns is written under the serve-web
1226+
/// data dir and reused on the next launch — but only when it is still bindable, so a
1227+
/// now-occupied port falls back to an OS-assigned one instead of failing the launch.
1228+
enum ServeWebPortStore {
1229+
static let fileName = "serve-web-port"
1230+
1231+
/// The `--port` argument for `code serve-web`: the persisted port when it is valid and
1232+
/// currently free, otherwise "0" (let the OS assign one). `isPortAvailable` is injectable
1233+
/// for testing; it defaults to a real loopback bind probe.
1234+
static func portArgument(
1235+
persistedIn directory: URL?,
1236+
isPortAvailable: (Int) -> Bool = ServeWebPortStore.isPortAvailable
1237+
) -> String {
1238+
guard let url = portFileURL(in: directory),
1239+
let data = try? Data(contentsOf: url),
1240+
let raw = String(data: data, encoding: .utf8),
1241+
let port = parsePort(raw),
1242+
isPortAvailable(port) else {
1243+
return "0"
1244+
}
1245+
return String(port)
1246+
}
1247+
1248+
/// Records the port VS Code assigned so the next launch can request it again. No-op for
1249+
/// out-of-range ports or when the data dir is unavailable.
1250+
static func persist(port: Int, in directory: URL?) {
1251+
guard isValidPort(port), let url = portFileURL(in: directory) else { return }
1252+
let dir = url.deletingLastPathComponent()
1253+
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
1254+
try? Data(String(port).utf8).write(to: url, options: .atomic)
1255+
}
1256+
1257+
/// Parses a stored port string, returning nil for malformed or out-of-range values.
1258+
static func parsePort(_ raw: String) -> Int? {
1259+
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
1260+
guard let port = Int(trimmed), isValidPort(port) else { return nil }
1261+
return port
1262+
}
1263+
1264+
/// True when a TCP socket can bind 127.0.0.1:port right now.
1265+
static func isPortAvailable(_ port: Int) -> Bool {
1266+
guard isValidPort(port) else { return false }
1267+
let fd = socket(AF_INET, SOCK_STREAM, 0)
1268+
guard fd >= 0 else { return false }
1269+
defer { close(fd) }
1270+
var reuse: Int32 = 1
1271+
_ = setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &reuse, socklen_t(MemoryLayout<Int32>.size))
1272+
var addr = sockaddr_in()
1273+
addr.sin_family = sa_family_t(AF_INET)
1274+
addr.sin_port = in_port_t(UInt16(port)).bigEndian
1275+
addr.sin_addr.s_addr = inet_addr("127.0.0.1")
1276+
let bound = withUnsafePointer(to: &addr) { pointer in
1277+
pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPointer in
1278+
bind(fd, sockaddrPointer, socklen_t(MemoryLayout<sockaddr_in>.size))
1279+
}
1280+
}
1281+
return bound == 0
1282+
}
1283+
1284+
private static func isValidPort(_ port: Int) -> Bool { (1...65535).contains(port) }
1285+
1286+
private static func portFileURL(in directory: URL?) -> URL? {
1287+
directory?.appendingPathComponent(fileName, isDirectory: false)
1288+
}
1289+
}
1290+
12191291
enum WorkspaceShortcutMapper {
12201292
/// Maps numbered workspace shortcuts to a zero-based workspace index.
12211293
/// 1...8 target fixed indices; 9 always targets the last workspace.
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import XCTest
2+
import Foundation
3+
import Darwin
4+
5+
#if canImport(Programa_DEV)
6+
@testable import Programa_DEV
7+
#elseif canImport(Programa)
8+
@testable import Programa
9+
#endif
10+
11+
/// Tests for #21: persisting and reusing the VS Code serve-web port so the embedded
12+
/// browser keeps the same URL across restarts, while falling back to an OS-assigned
13+
/// port when the persisted one is no longer free.
14+
final class ServeWebPortStoreTests: XCTestCase {
15+
private func makeTempDir() -> URL {
16+
let dir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
17+
.appendingPathComponent("serve-web-port-test-\(UUID().uuidString)", isDirectory: true)
18+
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
19+
return dir
20+
}
21+
22+
func testPortArgumentIsZeroWhenNoFile() {
23+
let dir = makeTempDir()
24+
XCTAssertEqual(
25+
ServeWebPortStore.portArgument(persistedIn: dir, isPortAvailable: { _ in true }),
26+
"0"
27+
)
28+
}
29+
30+
func testPersistThenReuseRoundTrips() {
31+
let dir = makeTempDir()
32+
ServeWebPortStore.persist(port: 50_321, in: dir)
33+
XCTAssertEqual(
34+
ServeWebPortStore.portArgument(persistedIn: dir, isPortAvailable: { _ in true }),
35+
"50321"
36+
)
37+
}
38+
39+
func testPersistedPortIgnoredWhenUnavailable() {
40+
let dir = makeTempDir()
41+
ServeWebPortStore.persist(port: 50_321, in: dir)
42+
XCTAssertEqual(
43+
ServeWebPortStore.portArgument(persistedIn: dir, isPortAvailable: { _ in false }),
44+
"0",
45+
"An occupied persisted port must fall back to OS-assigned"
46+
)
47+
}
48+
49+
func testNilDirectoryIsZero() {
50+
XCTAssertEqual(
51+
ServeWebPortStore.portArgument(persistedIn: nil, isPortAvailable: { _ in true }),
52+
"0"
53+
)
54+
}
55+
56+
func testPersistRejectsOutOfRangePorts() {
57+
let dir = makeTempDir()
58+
ServeWebPortStore.persist(port: 0, in: dir)
59+
ServeWebPortStore.persist(port: 70_000, in: dir)
60+
// Nothing valid was written, so the OS-assigned fallback still applies.
61+
XCTAssertEqual(
62+
ServeWebPortStore.portArgument(persistedIn: dir, isPortAvailable: { _ in true }),
63+
"0"
64+
)
65+
}
66+
67+
func testParsePort() {
68+
XCTAssertEqual(ServeWebPortStore.parsePort(" 8080 \n"), 8080)
69+
XCTAssertEqual(ServeWebPortStore.parsePort("1"), 1)
70+
XCTAssertEqual(ServeWebPortStore.parsePort("65535"), 65535)
71+
XCTAssertNil(ServeWebPortStore.parsePort(""))
72+
XCTAssertNil(ServeWebPortStore.parsePort("notaport"))
73+
XCTAssertNil(ServeWebPortStore.parsePort("0"))
74+
XCTAssertNil(ServeWebPortStore.parsePort("65536"))
75+
XCTAssertNil(ServeWebPortStore.parsePort("-1"))
76+
}
77+
78+
func testIsPortAvailableDetectsOccupiedPort() throws {
79+
// Bind+listen on an OS-assigned loopback port, then assert the store reports it busy.
80+
let listenFd = socket(AF_INET, SOCK_STREAM, 0)
81+
try XCTSkipIf(listenFd < 0, "could not create probe socket")
82+
defer { close(listenFd) }
83+
84+
var reuse: Int32 = 1
85+
_ = setsockopt(listenFd, SOL_SOCKET, SO_REUSEADDR, &reuse, socklen_t(MemoryLayout<Int32>.size))
86+
87+
var addr = sockaddr_in()
88+
addr.sin_family = sa_family_t(AF_INET)
89+
addr.sin_port = 0 // OS-assigned
90+
addr.sin_addr.s_addr = inet_addr("127.0.0.1")
91+
let bound = withUnsafePointer(to: &addr) { pointer in
92+
pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPointer in
93+
Darwin.bind(listenFd, sockaddrPointer, socklen_t(MemoryLayout<sockaddr_in>.size))
94+
}
95+
}
96+
try XCTSkipIf(bound != 0, "could not bind probe socket")
97+
try XCTSkipIf(listen(listenFd, 1) != 0, "could not listen on probe socket")
98+
99+
var boundAddr = sockaddr_in()
100+
var len = socklen_t(MemoryLayout<sockaddr_in>.size)
101+
let got = withUnsafeMutablePointer(to: &boundAddr) { pointer in
102+
pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPointer in
103+
getsockname(listenFd, sockaddrPointer, &len)
104+
}
105+
}
106+
try XCTSkipIf(got != 0, "could not read probe port")
107+
let port = Int(UInt16(bigEndian: boundAddr.sin_port))
108+
109+
XCTAssertFalse(
110+
ServeWebPortStore.isPortAvailable(port),
111+
"A port held by a live listener must report as unavailable"
112+
)
113+
}
114+
}

0 commit comments

Comments
 (0)