diff --git a/src/cliproxy/binary-manager.ts b/src/cliproxy/binary-manager.ts index bda4604a..f74d9257 100644 --- a/src/cliproxy/binary-manager.ts +++ b/src/cliproxy/binary-manager.ts @@ -9,7 +9,7 @@ import { info, warn } from '../utils/ui'; import { getBinDir, CLIPROXY_DEFAULT_PORT } from './config-generator'; import { BinaryInfo, BinaryManagerConfig } from './types'; import { BACKEND_CONFIG, DEFAULT_BACKEND, CLIPROXY_MAX_STABLE_VERSION } from './platform-detector'; -import { isProxyRunning, stopProxy } from './services/proxy-lifecycle-service'; +import { stopProxy } from './services/proxy-lifecycle-service'; import { waitForPortFree } from '../utils/port-utils'; import { loadOrCreateUnifiedConfig } from '../config/unified-config-loader'; import { @@ -167,19 +167,18 @@ export async function installCliproxyVersion( const effectiveBackend = backend ?? getConfiguredBackend(); const manager = new BinaryManager({ version, verbose, forceVersion: true }, effectiveBackend); - // Check if proxy is running and stop it first - if (isProxyRunning()) { - if (verbose) console.log(info('Stopping running CLIProxy before update...')); - const result = await stopProxy(); - if (result.stopped) { - // Wait for port to be fully released - const portFree = await waitForPortFree(CLIPROXY_DEFAULT_PORT, 5000); - if (!portFree && verbose) { - console.log(warn('Port did not free up in time, proceeding anyway...')); - } - } else if (verbose && result.error) { - console.log(warn(`Could not stop proxy: ${result.error}`)); + // Always attempt a best-effort stop first so we also catch untracked proxies + // that are running without a session lock. + if (verbose) console.log(info('Stopping running CLIProxy before update...')); + const result = await stopProxy(); + if (result.stopped) { + // Wait for port to be fully released + const portFree = await waitForPortFree(CLIPROXY_DEFAULT_PORT, 5000); + if (!portFree && verbose) { + console.log(warn('Port did not free up in time, proceeding anyway...')); } + } else if (verbose && result.error && result.error !== 'No active CLIProxy session found') { + console.log(warn(`Could not stop proxy: ${result.error}`)); } if (manager.isBinaryInstalled()) { diff --git a/src/web-server/services/cliproxy-dashboard-install-service.ts b/src/web-server/services/cliproxy-dashboard-install-service.ts index 60f99a60..7091965f 100644 --- a/src/web-server/services/cliproxy-dashboard-install-service.ts +++ b/src/web-server/services/cliproxy-dashboard-install-service.ts @@ -51,8 +51,8 @@ export async function installDashboardCliproxyVersion( const backendLabel = backend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy'; const shouldRestoreService = await wasProxyRunning(deps); - // The installer owns the stop-and-replace lifecycle: it stops a running proxy - // and waits for the port to free before swapping the binary. + // The installer owns the stop-and-replace lifecycle, including best-effort + // shutdown for tracked and untracked proxies before swapping the binary. await deps.installCliproxyVersion(version, true, backend); if (!shouldRestoreService) { diff --git a/tests/unit/cliproxy/binary-manager-install.test.ts b/tests/unit/cliproxy/binary-manager-install.test.ts new file mode 100644 index 00000000..b30bf963 --- /dev/null +++ b/tests/unit/cliproxy/binary-manager-install.test.ts @@ -0,0 +1,101 @@ +import { afterAll, beforeAll, describe, expect, it, mock } from 'bun:test'; + +const calls = { + stopProxy: 0, + waitForPortFree: 0, + deleteBinary: 0, + ensureBinary: 0, +}; + +mock.module('../../../src/utils/ui', () => ({ + info: (message: string) => message, + warn: (message: string) => message, +})); + +mock.module('../../../src/cliproxy/config-generator', () => ({ + getBinDir: () => '/tmp/ccs-bin', + CLIPROXY_DEFAULT_PORT: 8317, +})); + +mock.module('../../../src/cliproxy/platform-detector', () => ({ + DEFAULT_BACKEND: 'plus', + CLIPROXY_MAX_STABLE_VERSION: '6.6.80', + BACKEND_CONFIG: { + plus: { + fallbackVersion: '6.6.80', + repo: 'router-for-me/CLIProxyAPIPlus', + }, + original: { + fallbackVersion: '0.0.0', + repo: 'router-for-me/CLIProxyAPI', + }, + }, +})); + +mock.module('../../../src/cliproxy/services/proxy-lifecycle-service', () => ({ + stopProxy: async () => { + calls.stopProxy += 1; + return { stopped: false, error: 'No active CLIProxy session found' }; + }, +})); + +mock.module('../../../src/utils/port-utils', () => ({ + waitForPortFree: async () => { + calls.waitForPortFree += 1; + return true; + }, +})); + +mock.module('../../../src/config/unified-config-loader', () => ({ + loadOrCreateUnifiedConfig: () => ({ + cliproxy: { backend: 'plus' }, + }), +})); + +mock.module('../../../src/cliproxy/binary', () => ({ + checkForUpdates: async () => ({ + hasUpdate: false, + currentVersion: '6.6.80', + latestVersion: '6.6.80', + fromCache: false, + checkedAt: Date.now(), + }), + deleteBinary: () => { + calls.deleteBinary += 1; + }, + getBinaryPath: () => '/tmp/ccs-bin/plus/cliproxy', + isBinaryInstalled: () => false, + getBinaryInfo: async () => null, + getPinnedVersion: () => null, + savePinnedVersion: () => {}, + clearPinnedVersion: () => {}, + isVersionPinned: () => false, + getVersionPinPath: () => '/tmp/ccs-bin/plus/.version-pin', + readInstalledVersion: () => '6.6.80', + ensureBinary: async () => { + calls.ensureBinary += 1; + return '/tmp/ccs-bin/plus/cliproxy'; + }, + migrateVersionPin: () => {}, +})); + +let binaryManager: typeof import('../../../src/cliproxy/binary-manager'); + +beforeAll(async () => { + binaryManager = await import('../../../src/cliproxy/binary-manager'); +}); + +afterAll(() => { + mock.restore(); +}); + +describe('installCliproxyVersion', () => { + it('attempts to stop the proxy even when there is no tracked running session', async () => { + await binaryManager.installCliproxyVersion('6.7.1', false, 'plus'); + + expect(calls.stopProxy).toBe(1); + expect(calls.waitForPortFree).toBe(0); + expect(calls.deleteBinary).toBe(0); + expect(calls.ensureBinary).toBe(1); + }); +});