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
35 changes: 29 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -185,7 +194,15 @@ If a tool you use shows as not configured, run `tokentracker activate-if-needed`

<br/>

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
```

</details>

Expand Down Expand Up @@ -213,17 +230,23 @@ tokentracker doctor
</details>

<details>
<summary><b>Windows / PowerShell service starts but no browser opens</b></summary>
<summary><b>Windows / PowerShell: start TokenTracker with npx</b></summary>

<br/>

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.

</details>

Expand Down
50 changes: 1 addition & 49 deletions src/commands/serve.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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) {
Expand Down Expand Up @@ -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`);
},
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -283,7 +240,6 @@ async function listenOnAvailablePort(
host = LOCAL_BIND_HOST,
allowFallback = false,
maxAttempts = DEFAULT_MAX_PORT_ATTEMPTS,
ensurePortFreeFn = null,
onRetry = null,
} = {},
) {
Expand All @@ -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;
Expand Down
15 changes: 14 additions & 1 deletion test/serve-port-hint.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
);
});

Expand Down Expand Up @@ -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) => {
Expand Down