@@ -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+
12191291enum WorkspaceShortcutMapper {
12201292 /// Maps numbered workspace shortcuts to a zero-based workspace index.
12211293 /// 1...8 target fixed indices; 9 always targets the last workspace.
0 commit comments