diff --git a/dependency-injection.md b/dependency-injection.md index 10574c75da..56164068cd 100644 --- a/dependency-injection.md +++ b/dependency-injection.md @@ -324,6 +324,7 @@ contract and every existing caller sees it. | `ProjectNameService` | `projectNameService` | | `Prompter` | `prompter` | | `TempService` | `tempService` | +| `ViteHmrPortService` | `viteHmrPortService` | And the injection tokens, for registrations that are not classes: diff --git a/lib/bootstrap.ts b/lib/bootstrap.ts index 0346115632..634c916e47 100644 --- a/lib/bootstrap.ts +++ b/lib/bootstrap.ts @@ -419,6 +419,10 @@ injector.require( "bundlerCompilerService", "./services/bundler/bundler-compiler-service", ); +injector.require( + "viteHmrPortService", + "./services/bundler/vite-hmr-port-service", +); injector.require( "applePortalSessionService", diff --git a/lib/common/helpers.ts b/lib/common/helpers.ts index 6cdec0c478..878fcb9206 100644 --- a/lib/common/helpers.ts +++ b/lib/common/helpers.ts @@ -362,6 +362,18 @@ export function toBoolean(str: any): boolean { return !!(str && str.toString && str.toString().toLowerCase() === "true"); } +/** + * Reads an opt-in environment flag: any value other than empty / `0` / + * `false` / `off` / `no` turns it on. + */ +export function isTruthyEnvFlag(value: string | undefined): boolean { + if (typeof value !== "string") { + return false; + } + const v = value.trim().toLowerCase(); + return !!v && v !== "0" && v !== "false" && v !== "off" && v !== "no"; +} + export function block(operation: () => void): void { if (isInteractive()) { (process.stdin).setRawMode(false); diff --git a/lib/constants.ts b/lib/constants.ts index 7ac794cee0..729d257ccf 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -17,10 +17,11 @@ export const TNS_CORE_THEME_NAME = "nativescript-theme-core"; export const SCOPED_TNS_CORE_THEME_NAME = "@nativescript/theme"; export const WEBPACK_PLUGIN_NAME = "@nativescript/webpack"; export const RSPACK_PLUGIN_NAME = "@nativescript/rspack"; -// Project-relative directory the Vite bundler writes its build output to -// before the CLI copies it into the platforms app folder. Mirrors the -// default value computed in `@nativescript/vite`'s base configuration -// (`process.env.NS_VITE_DIST_DIR || '.ns-vite-build'`). +// Root of the project-relative directory the Vite bundler writes its build +// output to before the CLI copies it into the platforms app folder. The CLI +// stages each platform in its own subdirectory (`.ns-vite-build/`) +// and tells `@nativescript/vite` where via `NS_VITE_DIST_DIR`; the package's +// own fallback (`.ns-vite-build`) only applies to standalone `vite` runs. export const VITE_DIST_FOLDER_NAME = ".ns-vite-build"; export const TNS_CORE_MODULES_WIDGETS_NAME = "tns-core-modules-widgets"; export const UI_MOBILE_BASE_NAME = "@nativescript/ui-mobile-base"; diff --git a/lib/contracts/index.ts b/lib/contracts/index.ts index e2ad130627..c0c8e74d0e 100644 --- a/lib/contracts/index.ts +++ b/lib/contracts/index.ts @@ -42,6 +42,7 @@ export { ProjectDataService } from "./project-data-service"; export { ProjectNameService } from "./project-name-service"; export { Prompter } from "./prompter"; export { TempService } from "./temp-service"; +export { ViteHmrPortService } from "./vite-hmr-port-service"; export { PBXPROJ_DOM_XCODE } from "./pbxproj-dom-xcode"; export { XCODE } from "./xcode"; diff --git a/lib/contracts/vite-hmr-port-service.ts b/lib/contracts/vite-hmr-port-service.ts new file mode 100644 index 0000000000..a23bbca69f --- /dev/null +++ b/lib/contracts/vite-hmr-port-service.ts @@ -0,0 +1,20 @@ +import { Contract } from "../common/di/contract"; + +/** + * Chooses the local port the Vite HMR dev server binds for a platform. + */ +@Contract({ name: "viteHmrPortService" }) +export abstract class ViteHmrPortService { + /** + * Resolves the port the Vite dev server for `platform` listens on: the + * first free port at or above `NS_HMR_PORT` (default 5173) that no other + * platform in this process holds. Resolved once per platform and stable + * for the life of the process, so the build watcher (which bakes the port + * into `bundle.mjs`), the dev server and the Android `adb reverse` tunnel + * all agree on it. + * + * With `NS_HMR_STRICT_PORT` set, a busy preferred port fails instead of + * moving to the next one. + */ + abstract getPort(platform: string): Promise; +} diff --git a/lib/controllers/run-controller.ts b/lib/controllers/run-controller.ts index 942dea6c7a..925ffd3fc7 100644 --- a/lib/controllers/run-controller.ts +++ b/lib/controllers/run-controller.ts @@ -7,6 +7,7 @@ import { USER_INTERACTION_NEEDED_EVENT_NAME, } from "../constants"; import { cache, performanceLog } from "../common/decorators"; +import { isTruthyEnvFlag } from "../common/helpers"; import { EventEmitter } from "events"; import * as util from "util"; import * as _ from "lodash"; @@ -22,6 +23,7 @@ import { IDictionary, } from "../common/declarations"; import { IInjector } from "../common/definitions/yok"; +import { ViteHmrPortService } from "../contracts/vite-hmr-port-service"; import { injector } from "../common/yok"; export class RunController extends EventEmitter implements IRunController { @@ -58,6 +60,7 @@ export class RunController extends EventEmitter implements IRunController { private $projectChangesService: IProjectChangesService, protected $projectDataService: IProjectDataService, private $staticConfig: Config.IStaticConfig, + private $viteHmrPortService: ViteHmrPortService, ) { super(); } @@ -692,18 +695,20 @@ export class RunController extends EventEmitter implements IRunController { // Respect the user's explicit opt-out — they want the // `10.0.2.2` / LAN path, so don't create a tunnel or claim one // exists. - if (this.isTruthyEnvFlag(process.env.NS_HMR_NO_ADB_REVERSE)) { + if (isTruthyEnvFlag(process.env.NS_HMR_NO_ADB_REVERSE)) { return; } // `NS_HMR_PREFER_LAN_HOST` means the dev wants LAN routing // (physical device over Wi-Fi); the dev-host resolver suppresses // the adb-reverse path for it, so don't bother wiring one. - if (this.isTruthyEnvFlag(process.env.NS_HMR_PREFER_LAN_HOST)) { + if (isTruthyEnvFlag(process.env.NS_HMR_PREFER_LAN_HOST)) { return; } const serial = device.deviceInfo.identifier; - const port = this.getViteHmrPort(); + const port = await this.$viteHmrPortService.getPort( + device.deviceInfo.platform, + ); if (phase === "pre-build") { // Decide the origin baked into bundle.mjs. Hand the bundler our @@ -733,7 +738,7 @@ export class RunController extends EventEmitter implements IRunController { // + install (fresh emulators reconnect as they settle), silently // dropping the early mapping. We only bother when we actually told // the bundle to use `127.0.0.1` (READY set during pre-build). - if (!this.isTruthyEnvFlag(process.env.NS_ADB_REVERSE_READY)) { + if (!isTruthyEnvFlag(process.env.NS_ADB_REVERSE_READY)) { return; } const ok = await this.ensureAndroidReverse(device, serial, port); @@ -794,22 +799,6 @@ export class RunController extends EventEmitter implements IRunController { return false; } - private getViteHmrPort(): number { - // The Vite dev server defaults to 5173; the bundler reads the same - // default. If a project runs Vite on a different port, the dev sets - // `NS_HMR_PORT` so the CLI reverses the matching port. - const fromEnv = Number(process.env.NS_HMR_PORT); - return Number.isFinite(fromEnv) && fromEnv > 0 ? fromEnv : 5173; - } - - private isTruthyEnvFlag(value: string | undefined): boolean { - if (typeof value !== "string") { - return false; - } - const v = value.trim().toLowerCase(); - return !!v && v !== "0" && v !== "false" && v !== "off" && v !== "no"; - } - private async syncChangedDataOnDevices( data: IFilesChangeEventData, projectData: IProjectData, diff --git a/lib/services/bundler/bundler-compiler-service.ts b/lib/services/bundler/bundler-compiler-service.ts index 0a4bd8800e..fb3d345d81 100644 --- a/lib/services/bundler/bundler-compiler-service.ts +++ b/lib/services/bundler/bundler-compiler-service.ts @@ -33,6 +33,7 @@ import { IHostInfo, } from "../../common/declarations"; import { ICleanupService } from "../../definitions/cleanup-service"; +import { ViteHmrPortService } from "../../contracts/vite-hmr-port-service"; import { injector } from "../../common/yok"; import { resolvePackagePath, @@ -79,23 +80,37 @@ export class BundlerCompilerService private $packageManager: IPackageManager, private $packageInstallationManager: IPackageInstallationManager, // private $sharedEventBus: ISharedEventBus private $projectConfigService: IProjectConfigService, + private $viteHmrPortService: ViteHmrPortService, ) { super(); } - private getViteDistOutputPath(projectDir: string): string { - return path.join( - projectDir, - process.env.NS_VITE_DIST_DIR || VITE_DIST_FOLDER_NAME, + /** + * Project-relative directory Vite stages its output in before the CLI + * copies it into the platform app. Each platform gets its own directory + * so concurrent iOS and Android sessions (separate terminals or one + * `ns run`) never overwrite each other's bundle or vendor manifest. + * `NS_VITE_DIST_DIR` overrides it verbatim. + */ + private getViteDistRelativeDir(platform: string): string { + return ( + process.env.NS_VITE_DIST_DIR || `${VITE_DIST_FOLDER_NAME}/${platform}` ); } + private getViteDistOutputPath(projectDir: string, platform: string): string { + return path.join(projectDir, this.getViteDistRelativeDir(platform)); + } + private getViteBuildPaths( platformData: IPlatformData, projectData: IProjectData, ) { return { - distOutput: this.getViteDistOutputPath(projectData.projectDir), + distOutput: this.getViteDistOutputPath( + projectData.projectDir, + platformData.platformNameLowerCase, + ), destDir: path.join( platformData.appDestinationDirectoryPath, this.$options.hostProjectModuleName, @@ -566,6 +581,12 @@ export class BundlerCompilerService ...process.env, NATIVESCRIPT_WEBPACK_ENV: JSON.stringify(envData), NATIVESCRIPT_BUNDLER_ENV: JSON.stringify(envData), + ...(isVite + ? await this.getViteChildEnv( + platformData.platformNameLowerCase, + prepareData, + ) + : {}), }; if (this.$hostInfo.isWindows) { Object.assign(options.env, { APPDATA: process.env.appData }); @@ -595,16 +616,46 @@ export class BundlerCompilerService return childProcess; } - private getViteHmrPort(): number { - const fromEnv = Number(process.env.NS_HMR_PORT); - return Number.isFinite(fromEnv) && fromEnv > 0 ? fromEnv : 5173; + /** + * Whether this prepare runs the long-lived Vite HMR dev server (vite + + * HMR + watch, not release). + */ + private isViteHmrSession(prepareData: IPrepareData): boolean { + return ( + this.getBundler() === "vite" && + !!prepareData.watch && + !!prepareData.hmr && + !prepareData.release + ); + } + + /** + * Environment both Vite children (the build watcher and the dev server) + * must share for a platform: the staging directory, and — for HMR + * sessions — the dev-server port. The port is resolved here, once, and + * handed to `@nativescript/vite` as `NS_HMR_PORT`, so the URLs baked into + * `bundle.mjs`, the server's bind and the `adb reverse` tunnel all match. + */ + private async getViteChildEnv( + platform: string, + prepareData: IPrepareData, + ): Promise { + const env: IStringDictionary = { + NS_VITE_DIST_DIR: this.getViteDistRelativeDir(platform), + }; + if (this.isViteHmrSession(prepareData)) { + env.NS_HMR_PORT = String( + await this.$viteHmrPortService.getPort(platform), + ); + } + return env; } /** * Spawn and manage the Vite dev server (`vite serve`) for HMR. * * Why the CLI owns this. With Vite, HMR needs a long-lived dev server - * (HTTP + the `/ns-hmr` websocket on port 5173) that the device fetches + * (HTTP + the `/ns-hmr` websocket) that the device fetches * modules and hot updates from — it is SEPARATE from the * `vite build --watch` process that emits the `bundle.mjs` bootstrap * baked into the app. Historically users wired this up themselves with @@ -626,10 +677,7 @@ export class BundlerCompilerService prepareData: IPrepareData, ): Promise { try { - if (this.getBundler() !== "vite") { - return; - } - if (!prepareData.watch || !prepareData.hmr || prepareData.release) { + if (!this.isViteHmrSession(prepareData)) { return; } const key = platformData.platformNameLowerCase; @@ -637,18 +685,8 @@ export class BundlerCompilerService return; } - const port = this.getViteHmrPort(); - // One dev server per port. Simultaneous multi-platform HMR in a - // single CLI invocation would collide on 5173 — that case still - // needs a distinct NS_HMR_PORT per platform, so skip + warn rather - // than fail to bind. - const collidingPlatform = Object.keys(this.viteServeProcesses)[0]; - if (collidingPlatform) { - this.$logger.warn( - `Vite dev server already running for '${collidingPlatform}' on port ${port}; skipping a second server for '${key}'. For simultaneous multi-platform HMR, set a distinct NS_HMR_PORT per platform.`, - ); - return; - } + const viteEnv = await this.getViteChildEnv(key, prepareData); + const port = Number(viteEnv.NS_HMR_PORT); const envData = this.buildEnvData( platformData.platformNameLowerCase, @@ -690,6 +728,7 @@ export class BundlerCompilerService env: { ...process.env, NATIVESCRIPT_BUNDLER_ENV: JSON.stringify(envData), + ...viteEnv, }, }; if (this.$hostInfo.isWindows) { diff --git a/lib/services/bundler/vite-hmr-port-service.ts b/lib/services/bundler/vite-hmr-port-service.ts new file mode 100644 index 0000000000..74d77e696e --- /dev/null +++ b/lib/services/bundler/vite-hmr-port-service.ts @@ -0,0 +1,108 @@ +import * as net from "net"; +import { IDictionary, IErrors } from "../../common/declarations"; +import { isTruthyEnvFlag } from "../../common/helpers"; +import { injector } from "../../common/yok"; +import { ViteHmrPortService as ViteHmrPortServiceContract } from "../../contracts/vite-hmr-port-service"; + +const DEFAULT_PORT = 5173; +const MAX_PORT = 65535; + +export class ViteHmrPortServiceImpl implements ViteHmrPortServiceContract { + private ports: IDictionary> = {}; + private allocated = new Set(); + // Allocation runs one platform at a time: `ns run` resolves every + // device's platform concurrently, and two probes racing on the same + // free port would both claim it. + private queue: Promise = Promise.resolve(); + + constructor( + private $errors: IErrors, + private $logger: ILogger, + ) {} + + public getPort(platform: string): Promise { + const key = platform.toLowerCase(); + if (!this.ports[key]) { + this.ports[key] = this.queue.then(() => this.allocate(key)); + this.queue = this.ports[key].catch((): void => undefined); + } + return this.ports[key]; + } + + private async allocate(platform: string): Promise { + const preferred = this.getPreferredPort(); + const strict = isTruthyEnvFlag(process.env.NS_HMR_STRICT_PORT); + + for (let port = preferred; port <= MAX_PORT; port++) { + const busy = this.allocated.has(port) || !(await this.isPortFree(port)); + if (!busy) { + this.allocated.add(port); + if (port !== preferred) { + this.$logger.info( + `Vite dev server port ${preferred} is in use; using port ${port} for ${platform} instead.`, + ); + } + return port; + } + if (strict) { + this.$errors.fail( + `Vite dev server port ${preferred} is in use and NS_HMR_STRICT_PORT is set. Free the port, or pick another one with NS_HMR_PORT.`, + ); + } + } + + return this.$errors.fail( + `Unable to find a free port for the Vite dev server (tried ${preferred}-${MAX_PORT}). Set NS_HMR_PORT to a free port.`, + ); + } + + private getPreferredPort(): number { + const fromEnv = Number(process.env.NS_HMR_PORT); + return Number.isFinite(fromEnv) && fromEnv > 0 + ? Math.floor(fromEnv) + : DEFAULT_PORT; + } + + /** + * A port is free when the wildcard bind the dev server performs would + * succeed AND nothing answers on loopback. Both checks are needed: on + * macOS a listener bound only to `127.0.0.1` does not block a `0.0.0.0` + * bind, yet loopback is exactly what the device reaches through + * `adb reverse` and the iOS Simulator, so such a port must count as busy. + */ + private async isPortFree(port: number): Promise { + if (!(await this.canBindWildcard(port))) { + return false; + } + return !(await this.isLoopbackListening(port)); + } + + private canBindWildcard(port: number): Promise { + return new Promise((resolve) => { + const server = net.createServer(); + server.unref(); + // Only EADDRINUSE means "taken"; anything else (EACCES on a + // privileged port, an unsupported address family) is left for + // the dev server itself to report. + server.once("error", (err: NodeJS.ErrnoException) => + resolve(err.code !== "EADDRINUSE"), + ); + server.listen(port, "0.0.0.0", () => server.close(() => resolve(true))); + }); + } + + private isLoopbackListening(port: number): Promise { + return new Promise((resolve) => { + const socket = net.connect({ port, host: "127.0.0.1" }); + const done = (open: boolean) => { + socket.destroy(); + resolve(open); + }; + socket.once("connect", () => done(true)); + socket.once("error", () => done(false)); + socket.setTimeout(1000, () => done(false)); + }); + } +} + +injector.register("viteHmrPortService", ViteHmrPortServiceImpl); diff --git a/test/contracts.ts b/test/contracts.ts index ed9902b9db..e07581e01e 100644 --- a/test/contracts.ts +++ b/test/contracts.ts @@ -16,6 +16,7 @@ import { ProjectNameService, Prompter, TempService, + ViteHmrPortService, PBXPROJ_DOM_XCODE, XCODE, } from "../lib/contracts"; @@ -40,6 +41,7 @@ const tranche: [ProviderToken, string][] = [ [ProjectNameService, "projectNameService"], [Prompter, "prompter"], [TempService, "tempService"], + [ViteHmrPortService, "viteHmrPortService"], ]; describe("contracts tranche", () => { diff --git a/test/controllers/run-controller.ts b/test/controllers/run-controller.ts index b29bcebcce..c09eb73f12 100644 --- a/test/controllers/run-controller.ts +++ b/test/controllers/run-controller.ts @@ -134,6 +134,9 @@ function createTestInjector() { injector.register("staticConfig", { getAdbFilePath: async () => "adb", }); + injector.register("viteHmrPortService", { + getPort: async () => 5173, + }); const devicesService = injector.resolve("devicesService"); devicesService.getDevicesForPlatform = () => diff --git a/test/services/bundler/bundler-compiler-service.ts b/test/services/bundler/bundler-compiler-service.ts index eaebd03f58..ba6a0880a6 100644 --- a/test/services/bundler/bundler-compiler-service.ts +++ b/test/services/bundler/bundler-compiler-service.ts @@ -54,6 +54,9 @@ function createTestInjector( testInjector.register("fs", { exists: (filePath: string) => true, }); + testInjector.register("viteHmrPortService", { + getPort: async () => 5173, + }); return testInjector; } @@ -243,13 +246,23 @@ describe("BundlerCompilerService", () => { }); describe("getViteDistOutputPath", () => { - it("uses the current default directory when NS_VITE_DIST_DIR is unset", () => { + it("stages each platform in its own directory when NS_VITE_DIST_DIR is unset", () => { const previous = process.env.NS_VITE_DIST_DIR; try { delete process.env.NS_VITE_DIST_DIR; assert.strictEqual( - (bundlerCompilerService).getViteDistOutputPath("/project"), - path.join("/project", ".ns-vite-build"), + (bundlerCompilerService).getViteDistOutputPath( + "/project", + "ios", + ), + path.join("/project", ".ns-vite-build", "ios"), + ); + assert.strictEqual( + (bundlerCompilerService).getViteDistOutputPath( + "/project", + "android", + ), + path.join("/project", ".ns-vite-build", "android"), ); } finally { if (previous === undefined) { @@ -260,13 +273,16 @@ describe("BundlerCompilerService", () => { } }); - it("uses NS_VITE_DIST_DIR for platform-isolated output", () => { + it("uses NS_VITE_DIST_DIR verbatim when set", () => { const previous = process.env.NS_VITE_DIST_DIR; try { - process.env.NS_VITE_DIST_DIR = ".ns-vite-build/android"; + process.env.NS_VITE_DIST_DIR = "custom-dist"; assert.strictEqual( - (bundlerCompilerService).getViteDistOutputPath("/project"), - path.join("/project", ".ns-vite-build", "android"), + (bundlerCompilerService).getViteDistOutputPath( + "/project", + "ios", + ), + path.join("/project", "custom-dist"), ); } finally { if (previous === undefined) { @@ -278,6 +294,53 @@ describe("BundlerCompilerService", () => { }); }); + describe("getViteChildEnv", () => { + let previous: string; + beforeEach(() => { + previous = process.env.NS_VITE_DIST_DIR; + delete process.env.NS_VITE_DIST_DIR; + (bundlerCompilerService).getBundler = () => "vite"; + testInjector.resolve("viteHmrPortService").getPort = async ( + platform: string, + ) => (platform === "ios" ? 5173 : 5174); + }); + afterEach(() => { + if (previous === undefined) { + delete process.env.NS_VITE_DIST_DIR; + } else { + process.env.NS_VITE_DIST_DIR = previous; + } + }); + + it("hands HMR sessions the platform's staging dir and resolved port", async () => { + assert.deepEqual( + await (bundlerCompilerService).getViteChildEnv("android", { + watch: true, + hmr: true, + }), + { NS_VITE_DIST_DIR: ".ns-vite-build/android", NS_HMR_PORT: "5174" }, + ); + }); + + it("does not resolve a port for builds that run no dev server", async () => { + assert.deepEqual( + await (bundlerCompilerService).getViteChildEnv("ios", { + watch: true, + hmr: false, + }), + { NS_VITE_DIST_DIR: ".ns-vite-build/ios" }, + ); + assert.deepEqual( + await (bundlerCompilerService).getViteChildEnv("ios", { + watch: true, + hmr: true, + release: true, + }), + { NS_VITE_DIST_DIR: ".ns-vite-build/ios" }, + ); + }); + }); + describe("compileWithWatch", () => { it("fails when the value set for bundlerConfigPath is not existant file", async () => { const bundlerConfigPath = "some path.js"; @@ -399,7 +462,7 @@ describe("BundlerCompilerService", () => { assert.deepEqual(copies, [ { - distOutput: path.join("/project", ".ns-vite-build"), + distOutput: path.join("/project", ".ns-vite-build", "android"), destDir: path.join("/project/platforms/android", "app"), failOnError: true, }, diff --git a/test/services/bundler/vite-hmr-port-service.ts b/test/services/bundler/vite-hmr-port-service.ts new file mode 100644 index 0000000000..2db0b68f67 --- /dev/null +++ b/test/services/bundler/vite-hmr-port-service.ts @@ -0,0 +1,114 @@ +import { assert } from "chai"; +import * as net from "net"; +import { Yok } from "../../../lib/common/yok"; +import { IInjector } from "../../../lib/common/definitions/yok"; +import { ErrorsStub, LoggerStub } from "../../stubs"; +import { ViteHmrPortServiceImpl } from "../../../lib/services/bundler/vite-hmr-port-service"; + +const ENV_KEYS = ["NS_HMR_PORT", "NS_HMR_STRICT_PORT"] as const; + +function listen(host: string, port = 0): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.once("error", reject); + server.listen(port, host, () => resolve(server)); + }); +} + +function close(server: net.Server): Promise { + return new Promise((resolve) => server.close(() => resolve())); +} + +function portOf(server: net.Server): number { + return (server.address()).port; +} + +function createService(): ViteHmrPortServiceImpl { + const injector: IInjector = new Yok(); + injector.register("errors", ErrorsStub); + injector.register("logger", LoggerStub); + injector.register("viteHmrPortService", ViteHmrPortServiceImpl); + return injector.resolve("viteHmrPortService"); +} + +describe("ViteHmrPortService", () => { + const savedEnv: Partial> = {}; + const holders: net.Server[] = []; + + beforeEach(() => { + for (const key of ENV_KEYS) { + savedEnv[key] = process.env[key]; + delete process.env[key]; + } + }); + + afterEach(async () => { + for (const key of ENV_KEYS) { + if (savedEnv[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = savedEnv[key]; + } + } + await Promise.all(holders.splice(0).map(close)); + }); + + it("uses the preferred port when it is free", async () => { + // Grab an ephemeral port and release it so it is (almost certainly) + // free for the service to pick. + const probe = await listen("0.0.0.0"); + const free = portOf(probe); + await close(probe); + process.env.NS_HMR_PORT = String(free); + + assert.strictEqual(await createService().getPort("ios"), free); + }); + + it("moves past a port held on the wildcard address", async () => { + const holder = await listen("0.0.0.0"); + holders.push(holder); + process.env.NS_HMR_PORT = String(portOf(holder)); + + const port = await createService().getPort("ios"); + assert.isAbove(port, portOf(holder)); + const server = await listen("0.0.0.0", port); + holders.push(server); + }); + + it("moves past a port that only answers on loopback", async () => { + const holder = await listen("127.0.0.1"); + holders.push(holder); + process.env.NS_HMR_PORT = String(portOf(holder)); + + const port = await createService().getPort("android"); + assert.isAbove(port, portOf(holder)); + }); + + it("resolves the same port for a platform on every call", async () => { + const service = createService(); + const first = await service.getPort("ios"); + assert.strictEqual(await service.getPort("ios"), first); + assert.strictEqual(await service.getPort("iOS"), first); + }); + + it("gives concurrently requested platforms distinct ports", async () => { + const service = createService(); + const [ios, android] = await Promise.all([ + service.getPort("ios"), + service.getPort("android"), + ]); + assert.notStrictEqual(ios, android); + }); + + it("fails instead of moving when NS_HMR_STRICT_PORT is set", async () => { + const holder = await listen("0.0.0.0"); + holders.push(holder); + process.env.NS_HMR_PORT = String(portOf(holder)); + process.env.NS_HMR_STRICT_PORT = "1"; + + await assert.isRejected( + createService().getPort("ios"), + /NS_HMR_STRICT_PORT/, + ); + }); +});