diff --git a/CHANGELOG.md b/CHANGELOG.md index 8db6c4d..bef6998 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,5 @@ -- [2026-07-17] drop the one-shot home migration +- [2026-07-20] replace an unreachable daemon holding the port, fixes #3 +- [2026-07-17] [drop the one-shot home migration](https://github.com/RubricLab/tokenmaxx/commit/f1cbbe575fb51a2b5bd9b9a3c2415900a31ccd0d) - [2026-07-17] [home is ~/.tokenmaxx](https://github.com/RubricLab/tokenmaxx/commit/ae233348f9287b5724b8b66a78fc700a9687f40b) - [2026-07-17] [exit when the terminal goes away, fixes #2](https://github.com/RubricLab/tokenmaxx/commit/33e9e1b1e2c1cd5a3948675dc3ca298da2a7ed88) - [2026-07-17] [update rubriclab package](https://github.com/RubricLab/tokenmaxx/commit/6fbdedfde9cb80257a9ce7085747f93d9f1f421c) diff --git a/package.json b/package.json index 7e7bf2b..745e4e2 100644 --- a/package.json +++ b/package.json @@ -59,5 +59,5 @@ "post-commit": "bun x @rubriclab/package post-commit" }, "type": "module", - "version": "0.0.42" + "version": "0.0.43" } diff --git a/src/cli.ts b/src/cli.ts index 951f987..a151547 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -27,6 +27,7 @@ import { } from './ipc.ts' import { AccountManager } from './manager.ts' import { type ApplicationPaths, applicationPaths, ensureApplicationPaths } from './paths.ts' +import { proxyIdentity } from './proxy.ts' import { createStateStore, type StateStore } from './storage.ts' import { renderDashboard } from './ui.ts' import { createMacOsKeychainVault } from './vault.ts' @@ -129,6 +130,56 @@ async function commandOutput(command: readonly string[]): Promise { return stdout.trim() || stderr.trim() } +async function portOwnerProcessId(port: number): Promise { + const output = await commandOutput(['lsof', '-nP', '-t', `-iTCP:${port}`, '-sTCP:LISTEN']) + const processId = Number(output.split('\n')[0]) + return Number.isInteger(processId) && processId > 0 ? processId : null +} + +async function portFreed(port: number, deadlineMilliseconds: number): Promise { + const deadline = Date.now() + deadlineMilliseconds + while ((await proxyIdentity(port)) !== null) { + if (Date.now() > deadline) { + return false + } + await Bun.sleep(200) + } + return true +} + +async function replacePortOccupant(port: number): Promise { + const identity = await proxyIdentity(port) + if (identity === null) { + return + } + const processId = await portOwnerProcessId(port) + if (identity === 'foreign' || processId === null) { + throw new ApplicationError( + 'PROXY_PORT_IN_USE', + `Port ${port} is in use by another program${processId === null ? '' : ` (process ${processId})`}; stop it or set TOKENMAXX_PROXY_PORT` + ) + } + process.stdout.write( + `Replacing an unreachable tokenmaxx daemon (process ${processId}) holding port ${port}…\n` + ) + try { + process.kill(processId, 'SIGTERM') + } catch {} + if (await portFreed(port, 5_000)) { + return + } + try { + process.kill(processId, 'SIGKILL') + } catch {} + if (await portFreed(port, 2_000)) { + return + } + throw new ApplicationError( + 'PROXY_PORT_IN_USE', + `Port ${port} is still held by process ${processId}` + ) +} + const CommandSchema = z.array(z.string()) const EmptyResultSchema = z.unknown() @@ -279,6 +330,7 @@ async function startDaemon(context: ApplicationContext): Promise { if (await managerAvailable(context.paths.managerSocket)) { return } + await replacePortOccupant(context.paths.proxyPort) await mkdir(context.paths.runtime, { mode: 0o700, recursive: true }) const entrypoint = process.argv[1] if (entrypoint === undefined) { @@ -619,9 +671,17 @@ async function doctor(context: ApplicationContext): Promise { process.stdout.write(`${Bun.which('security') === null ? 'missing' : 'ok '} security\n`) const running = await managerAvailable(context.paths.managerSocket) const daemonVersion = running ? await managerVersion(context.paths.managerSocket) : null - process.stdout.write( - `${running ? 'running' : 'stopped'} manager daemon${daemonVersion === null ? '' : ` (${daemonVersion})`}\n` - ) + const unreachable = !running && (await proxyIdentity(context.paths.proxyPort)) === 'tokenmaxx' + if (unreachable) { + const processId = await portOwnerProcessId(context.paths.proxyPort) + process.stdout.write( + `warning manager daemon${processId === null ? '' : ` (process ${processId})`} holds port ${context.paths.proxyPort} but does not answer — tokenmaxx daemon start replaces it\n` + ) + } else { + process.stdout.write( + `${running ? 'running' : 'stopped'} manager daemon${daemonVersion === null ? '' : ` (${daemonVersion})`}\n` + ) + } const update = await availableUpdate() process.stdout.write( update === null @@ -793,11 +853,22 @@ export async function runCli(rawArguments: readonly string[]): Promise { case 'stop': await stopDaemon(context) return 0 - case 'status': - process.stdout.write( - `${(await managerAvailable(context.paths.managerSocket)) ? 'running' : 'stopped'}\n` - ) + case 'status': { + if (await managerAvailable(context.paths.managerSocket)) { + process.stdout.write('running\n') + return 0 + } + const port = context.paths.proxyPort + if ((await proxyIdentity(port)) === 'tokenmaxx') { + const processId = await portOwnerProcessId(port) + process.stdout.write( + `unreachable — a tokenmaxx daemon${processId === null ? '' : ` (process ${processId})`} holds port ${port} but does not answer; tokenmaxx daemon start replaces it\n` + ) + return 1 + } + process.stdout.write('stopped\n') return 0 + } default: throw new ApplicationError('USAGE', 'Usage: daemon ') } diff --git a/src/proxy.test.ts b/src/proxy.test.ts index 160e954..5189685 100644 --- a/src/proxy.test.ts +++ b/src/proxy.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test' -import { createUsageObserver } from './proxy.ts' +import { createUsageObserver, proxyIdentity, startProxy } from './proxy.ts' type Observed = { model: string | null @@ -115,3 +115,41 @@ describe('createUsageObserver', () => { expect(observe('openai', body)?.outputTokens).toBe(2) }) }) + +describe('proxyIdentity', () => { + test('recognizes any tokenmaxx proxy by its unknown-route response', async () => { + const proxy = startProxy({ + source: { refresh: async () => undefined, resolve: async () => null } + }) + expect(await proxyIdentity(proxy.port)).toBe('tokenmaxx') + await proxy.stop() + }) + + test('reports a foreign listener without claiming it', async () => { + const server = Bun.serve({ + fetch: () => new Response('hello'), + hostname: '127.0.0.1', + port: 0 + }) + const port = server.port + if (port === undefined) { + throw new Error('server did not bind') + } + expect(await proxyIdentity(port)).toBe('foreign') + await server.stop(true) + }) + + test('reports a free port as nothing listening', async () => { + const server = Bun.serve({ + fetch: () => new Response(''), + hostname: '127.0.0.1', + port: 0 + }) + const port = server.port + if (port === undefined) { + throw new Error('server did not bind') + } + await server.stop(true) + expect(await proxyIdentity(port)).toBe(null) + }) +}) diff --git a/src/proxy.ts b/src/proxy.ts index dd0ef6a..88a7469 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -186,6 +186,20 @@ function observeStream( ) } +const proxyFingerprint = 'tokenmaxx proxy' + +export async function proxyIdentity(port: number): Promise<'tokenmaxx' | 'foreign' | null> { + try { + const response = await fetch(`http://127.0.0.1:${port}/`, { + signal: AbortSignal.timeout(1_000) + }) + const body = await response.text() + return body.startsWith(proxyFingerprint) ? 'tokenmaxx' : 'foreign' + } catch { + return null + } +} + const strippedRequestHeaders = [ 'host', 'connection', @@ -268,7 +282,7 @@ function createProxyHandler(options: ProxyOptions): ProxyHandler { const url = new URL(request.url) const route = routeProvider(url.pathname) if (route === null) { - return new Response('tokenmaxx proxy: unknown route\n', { status: 404 }) + return new Response(`${proxyFingerprint}: unknown route\n`, { status: 404 }) } const body = request.method === 'GET' || request.method === 'HEAD' ? undefined : await request.arrayBuffer()