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
24 changes: 16 additions & 8 deletions cli/src/hooks/use-exit-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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])

Expand All @@ -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)
Expand Down
21 changes: 18 additions & 3 deletions cli/src/hooks/use-freebuff-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
69 changes: 69 additions & 0 deletions cli/src/utils/__tests__/freebuff-instance-owner.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
66 changes: 66 additions & 0 deletions cli/src/utils/freebuff-instance-owner.ts
Original file line number Diff line number Diff line change
@@ -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<FreebuffInstanceOwner>
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)
}
Loading