diff --git a/README.md b/README.md index 2db2185a..cd5e1bb5 100644 --- a/README.md +++ b/README.md @@ -132,7 +132,16 @@ Most users never touch this — defaults are sensible. | `GEMINI_HOME` | Override Gemini CLI directory | `~/.gemini` | | `TOKENTRACKER_GROK_HOME` | Override Grok Build directory | `~/.grok` | -To force a dashboard port: `PORT=7700 tokentracker serve` (otherwise it auto-picks the next free port from `7680`). +To force a dashboard port, use your shell's environment-variable syntax (otherwise TokenTracker auto-picks the next free port from `7680`): + +```bash +PORT=7700 npx --yes @ipv9/tokentracker-cli serve +``` + +```powershell +$env:PORT = 7700 +npx --yes @ipv9/tokentracker-cli serve +``` Browser auto-open is opt-in: `tokentracker serve --open`. Background services and headless shells should use the default no-open behavior and open the printed URL manually. @@ -185,7 +194,15 @@ If a tool you use shows as not configured, run `tokentracker activate-if-needed`
-The server auto-picks the next free port (`7681`, `7682`, …) and logs it on startup. To force one: `PORT=7700 tokentracker serve`. To see what's holding the port: `lsof -i :7680`. +The server auto-picks the next free port (`7681`, `7682`, …) and logs it on startup. To force one, use the Bash or PowerShell command in [Configuration](#configuration). To see what's holding port `7680`: + +```bash +lsof -i :7680 +``` + +```powershell +Get-NetTCPConnection -LocalPort 7680 -ErrorAction SilentlyContinue +``` @@ -213,17 +230,23 @@ tokentracker doctor
-Windows / PowerShell service starts but no browser opens +Windows / PowerShell: start TokenTracker with npx
-This is expected for background tasks. Scheduled tasks and the Windows tray app run: +For an interactive PowerShell session: + +```powershell +npx --yes @ipv9/tokentracker-cli serve --open +``` + +For headless PowerShell, CI, or a background process, keep browser opening disabled: ```powershell -tokentracker serve --no-sync --no-open +npx --yes @ipv9/tokentracker-cli serve --no-sync --no-open ``` -Open the printed local URL in your browser or use the tray app WebView. For an interactive PowerShell session, use `tokentracker serve --open`; TokenTracker uses the Windows shell opener instead of `xdg-open`. +TokenTracker never stops another process to free a port. When no port is specified it tries the next one up (`7681`, `7682`, …); an explicit `--port` or `$env:PORT` that is already in use fails startup and prints an alternative command to run.
diff --git a/src/commands/serve.js b/src/commands/serve.js index 946c86d2..77631328 100644 --- a/src/commands/serve.js +++ b/src/commands/serve.js @@ -2,7 +2,6 @@ const http = require("node:http"); const os = require("node:os"); const path = require("node:path"); const fssync = require("node:fs"); -const cp = require("node:child_process"); const { resolveTrackerPaths } = require("../lib/tracker-paths"); const { createLocalApiHandler, resolveQueuePath } = require("../lib/local-api"); @@ -20,7 +19,7 @@ const NPM_PACKAGE_NAME = "@ipv9/tokentracker-cli"; const LOCAL_BIND_HOST = "127.0.0.1"; function buildPortInUseHint(port) { - return `Port ${port} is still in use after cleanup. Try: npx ${NPM_PACKAGE_NAME} serve --port ${port + 1}\n`; + return `Port ${port} is unavailable. Try: npx ${NPM_PACKAGE_NAME} serve --port ${port + 1}\n`; } function isPortUnavailableError(error) { @@ -155,7 +154,6 @@ async function cmdServe(argv) { try { port = await listenOnAvailablePort(server, opts.port, { allowFallback: !opts.portExplicit, - ensurePortFreeFn: opts.portExplicit ? ensurePortFree : null, onRetry: (failedPort) => { process.stdout.write(`Port ${failedPort} unavailable, trying ${failedPort + 1}...\n`); }, @@ -208,47 +206,6 @@ async function cmdServe(argv) { await new Promise(() => {}); } -function findPidOnPort(port) { - try { - const out = cp.execFileSync("lsof", ["-ti", `tcp:${port}`], { encoding: "utf8", timeout: 5000 }); - const pids = out.trim().split(/\s+/).map(Number).filter((n) => Number.isFinite(n) && n > 0); - return pids; - } catch (_e) { - return []; - } -} - -async function ensurePortFree(port) { - const pids = findPidOnPort(port); - if (pids.length === 0) return; - - // Don't kill ourselves - const self = process.pid; - const targets = pids.filter((p) => p !== self); - if (targets.length === 0) return; - - process.stdout.write(`Stopping previous server on port ${port} (pid ${targets.join(", ")})...\n`); - for (const pid of targets) { - try { - process.kill(pid, "SIGTERM"); - } catch (_e) {} - } - - // Wait briefly for port to free up - for (let i = 0; i < 10; i++) { - await new Promise((r) => setTimeout(r, 300)); - if (findPidOnPort(port).length === 0) return; - } - - // Force kill if still alive - for (const pid of targets) { - try { - process.kill(pid, "SIGKILL"); - } catch (_e) {} - } - await new Promise((r) => setTimeout(r, 500)); -} - function listenOnce(server, port, host) { return new Promise((resolve, reject) => { let settled = false; @@ -283,7 +240,6 @@ async function listenOnAvailablePort( host = LOCAL_BIND_HOST, allowFallback = false, maxAttempts = DEFAULT_MAX_PORT_ATTEMPTS, - ensurePortFreeFn = null, onRetry = null, } = {}, ) { @@ -292,10 +248,6 @@ async function listenOnAvailablePort( let lastError = null; for (let i = 0; i < attempts && port < 65536; i++, port++) { - if (ensurePortFreeFn) { - await ensurePortFreeFn(port); - } - try { await listenOnce(server, port, host); return port; diff --git a/test/serve-port-hint.test.js b/test/serve-port-hint.test.js index a23372b0..6c33655a 100644 --- a/test/serve-port-hint.test.js +++ b/test/serve-port-hint.test.js @@ -14,7 +14,7 @@ test("serve port collision hint references the published npm package name", () = assert.equal(NPM_PACKAGE_NAME, "@ipv9/tokentracker-cli"); assert.equal( buildPortInUseHint(7681), - "Port 7681 is still in use after cleanup. Try: npx @ipv9/tokentracker-cli serve --port 7682\n", + "Port 7681 is unavailable. Try: npx @ipv9/tokentracker-cli serve --port 7682\n", ); }); @@ -52,6 +52,19 @@ test("serve default startup falls through to the next available port", async (t) assert.equal(selectedPort, occupiedPort + 1); }); +test("explicit port fails without stopping the process already holding it", async (t) => { + const occupied = http.createServer((_req, res) => res.end("occupied")); + await new Promise((resolve) => occupied.listen(0, "127.0.0.1", resolve)); + t.after(() => closeServer(occupied)); + + const server = http.createServer(); + t.after(() => closeServer(server)); + const port = occupied.address().port; + + await assert.rejects(() => listenOnAvailablePort(server, port), { code: "EADDRINUSE" }); + assert.equal(occupied.listening, true); +}); + function closeServer(server) { return new Promise((resolve, reject) => { server.close((error) => {