diff --git a/cli/src/hooks/use-exit-handler.ts b/cli/src/hooks/use-exit-handler.ts index a938540d8..e0ab54ff0 100644 --- a/cli/src/hooks/use-exit-handler.ts +++ b/cli/src/hooks/use-exit-handler.ts @@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { getCurrentChatId } from '../project-files' import { flushAnalytics } from '../utils/analytics' import { IS_FREEBUFF } from '../utils/constants' +import { exitFreebuffCleanly } from '../utils/freebuff-exit' import { withTimeout } from '../utils/terminal-color-detection' import type { InputValue } from '../types/store' @@ -38,6 +39,19 @@ function setupExitMessageHandler() { }) } +function exitCli(): void { + if (IS_FREEBUFF) { + void exitFreebuffCleanly() + return + } + + withTimeout(flushAnalytics(), EXIT_FLUSH_TIMEOUT_MS, undefined).finally( + () => { + process.exit(0) + }, + ) +} + export const useExitHandler = ({ inputValue, setInputValue, @@ -70,9 +84,7 @@ export const useExitHandler = ({ exitWarningTimeoutRef.current = null } - withTimeout(flushAnalytics(), EXIT_FLUSH_TIMEOUT_MS, undefined).then(() => { - process.exit(0) - }) + exitCli() return true }, [inputValue, setInputValue, nextCtrlCWillExit]) @@ -83,11 +95,7 @@ export const useExitHandler = ({ exitWarningTimeoutRef.current = null } - withTimeout(flushAnalytics(), EXIT_FLUSH_TIMEOUT_MS, undefined).finally( - () => { - process.exit(0) - }, - ) + exitCli() } process.on('SIGINT', handleSigint) diff --git a/cli/src/hooks/use-freebuff-session.ts b/cli/src/hooks/use-freebuff-session.ts index 332ab6450..cfd82a5ff 100644 --- a/cli/src/hooks/use-freebuff-session.ts +++ b/cli/src/hooks/use-freebuff-session.ts @@ -12,6 +12,10 @@ import { import { useFreebuffSessionStore } from '../state/freebuff-session-store' import { getAuthTokenDetails } from '../utils/auth' import { IS_FREEBUFF } from '../utils/constants' +import { + isFreebuffInstanceOwnedByDeadLocalProcess, + recordFreebuffInstanceOwner, +} from '../utils/freebuff-instance-owner' import { logger } from '../utils/logger' import { saveFreebuffModelPreference } from '../utils/settings' @@ -363,9 +367,9 @@ interface UseFreebuffSessionResult { * Manages the freebuff waiting-room session lifecycle: * - GET on mount to probe state (no auto-join; the user picks a model in * the landing screen, which calls joinFreebuffQueue) - * - if the probe sees an existing seat, asks before POSTing to take over - * (rotates the instance id so any other CLI on the same account is - * superseded) + * - if the probe sees an existing seat, auto-takes-over when the prior + * local owner process is gone; otherwise asks before POSTing to rotate + * the instance id so any other CLI on the same account is superseded * - polls GET while queued (fast) or active (slow) to keep state fresh * - re-POSTs on explicit refresh (chat gate rejected us, user switched * models, user rejoined after ending) @@ -406,6 +410,9 @@ export function useFreebuffSession(): UseFreebuffSessionResult { let nextMethod: 'GET' | 'POST' = 'GET' const apply = (next: FreebuffSessionResponse) => { + if (next.status === 'queued' || next.status === 'active') { + recordFreebuffInstanceOwner(next.instanceId) + } setSession(next) setError(null) previousStatus = next.status @@ -479,6 +486,14 @@ export function useFreebuffSession(): UseFreebuffSessionResult { (next.status === 'queued' || next.status === 'active') ) { useFreebuffModelStore.getState().setSelectedModel(next.model) + // A fast restart after Ctrl+C can observe the old server row before + // best-effort DELETE lands. If the row belongs to a dead local + // process, silently do the same POST as the Take over button. + if (isFreebuffInstanceOwnedByDeadLocalProcess(next.instanceId)) { + nextMethod = 'POST' + schedule(0) + return + } apply({ status: 'takeover_prompt', model: next.model }) return } diff --git a/cli/src/utils/__tests__/freebuff-instance-owner.test.ts b/cli/src/utils/__tests__/freebuff-instance-owner.test.ts new file mode 100644 index 000000000..d8aacaf41 --- /dev/null +++ b/cli/src/utils/__tests__/freebuff-instance-owner.test.ts @@ -0,0 +1,69 @@ +import fs from 'fs' +import os from 'os' +import path from 'path' + +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' + +import { ensureCliTestEnv } from '../../__tests__/test-utils' + +const OWNER_FILE = 'freebuff-instance-owner.json' + +ensureCliTestEnv() + +const { getConfigDir } = await import('../auth') +const { + isFreebuffInstanceOwnedByDeadLocalProcess, + recordFreebuffInstanceOwner, +} = await import('../freebuff-instance-owner') + +describe('freebuff instance owner', () => { + let originalHome: string | undefined + let tempHome: string + + const ownerPath = () => path.join(getConfigDir(), OWNER_FILE) + + beforeEach(() => { + originalHome = process.env.HOME + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'freebuff-owner-')) + process.env.HOME = tempHome + }) + + afterEach(() => { + if (originalHome === undefined) { + delete process.env.HOME + } else { + process.env.HOME = originalHome + } + fs.rmSync(tempHome, { recursive: true, force: true }) + }) + + test('does not classify the current process as dead', () => { + recordFreebuffInstanceOwner('inst-current') + + expect( + isFreebuffInstanceOwnedByDeadLocalProcess('inst-current'), + ).toBe(false) + }) + + test('classifies a matching owner with a dead pid as dead', () => { + fs.mkdirSync(getConfigDir(), { recursive: true }) + fs.writeFileSync( + ownerPath(), + JSON.stringify({ instanceId: 'inst-dead', pid: 2_147_483_647 }), + ) + + expect(isFreebuffInstanceOwnedByDeadLocalProcess('inst-dead')).toBe(true) + }) + + test('ignores a dead pid for a different instance id', () => { + fs.mkdirSync(getConfigDir(), { recursive: true }) + fs.writeFileSync( + ownerPath(), + JSON.stringify({ instanceId: 'inst-other', pid: 2_147_483_647 }), + ) + + expect( + isFreebuffInstanceOwnedByDeadLocalProcess('inst-current'), + ).toBe(false) + }) +}) diff --git a/cli/src/utils/freebuff-instance-owner.ts b/cli/src/utils/freebuff-instance-owner.ts new file mode 100644 index 000000000..a15881e54 --- /dev/null +++ b/cli/src/utils/freebuff-instance-owner.ts @@ -0,0 +1,66 @@ +import fs from 'fs' +import path from 'path' + +import { getConfigDir } from './auth' +import { logger } from './logger' + +interface FreebuffInstanceOwner { + instanceId: string + pid: number +} + +const OWNER_FILE = 'freebuff-instance-owner.json' + +const getOwnerPath = (): string => path.join(getConfigDir(), OWNER_FILE) + +function readOwner(): FreebuffInstanceOwner | null { + try { + const raw = fs.readFileSync(getOwnerPath(), 'utf8') + const parsed = JSON.parse(raw) as Partial + if ( + typeof parsed.instanceId !== 'string' || + typeof parsed.pid !== 'number' + ) { + return null + } + return { + instanceId: parsed.instanceId, + pid: parsed.pid, + } + } catch { + return null + } +} + +function isProcessRunning(pid: number): boolean { + if (!Number.isInteger(pid) || pid <= 0) return false + try { + process.kill(pid, 0) + return true + } catch (error) { + return (error as NodeJS.ErrnoException).code === 'EPERM' + } +} + +export function recordFreebuffInstanceOwner(instanceId: string): void { + try { + fs.mkdirSync(getConfigDir(), { recursive: true }) + fs.writeFileSync( + getOwnerPath(), + JSON.stringify({ instanceId, pid: process.pid }, null, 2), + ) + } catch (error) { + logger.debug( + { error: error instanceof Error ? error.message : String(error) }, + '[freebuff-session] Failed to record local owner', + ) + } +} + +export function isFreebuffInstanceOwnedByDeadLocalProcess( + instanceId: string, +): boolean { + const owner = readOwner() + if (!owner || owner.instanceId !== instanceId) return false + return !isProcessRunning(owner.pid) +}