mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 04:18:05 +00:00
fix(proxy): preserve legacy state and exact ports
This commit is contained in:
@@ -25,3 +25,11 @@ export function getOpenAICompatProxySessionPath(profileName: string): string {
|
||||
`${getOpenAICompatProxyProfileKey(profileName)}.session.json`
|
||||
);
|
||||
}
|
||||
|
||||
export function getLegacyOpenAICompatProxyPidPath(): string {
|
||||
return path.join(getOpenAICompatProxyDir(), 'daemon.pid');
|
||||
}
|
||||
|
||||
export function getLegacyOpenAICompatProxySessionPath(): string {
|
||||
return path.join(getOpenAICompatProxyDir(), 'session.json');
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
getOpenAICompatProxyDir,
|
||||
getLegacyOpenAICompatProxyPidPath,
|
||||
getLegacyOpenAICompatProxySessionPath,
|
||||
getOpenAICompatProxyPidPath,
|
||||
getOpenAICompatProxySessionPath,
|
||||
} from './proxy-daemon-paths';
|
||||
@@ -21,9 +23,9 @@ function ensureProxyDir(): void {
|
||||
fs.mkdirSync(getOpenAICompatProxyDir(), { recursive: true });
|
||||
}
|
||||
|
||||
export function getOpenAICompatProxyPid(profileName: string): number | null {
|
||||
function readPid(pidPath: string): number | null {
|
||||
try {
|
||||
const raw = fs.readFileSync(getOpenAICompatProxyPidPath(profileName), 'utf8').trim();
|
||||
const raw = fs.readFileSync(pidPath, 'utf8').trim();
|
||||
const pid = Number.parseInt(raw, 10);
|
||||
return Number.isInteger(pid) ? pid : null;
|
||||
} catch {
|
||||
@@ -31,6 +33,14 @@ export function getOpenAICompatProxyPid(profileName: string): number | null {
|
||||
}
|
||||
}
|
||||
|
||||
export function getOpenAICompatProxyPid(profileName: string): number | null {
|
||||
return readPid(getOpenAICompatProxyPidPath(profileName));
|
||||
}
|
||||
|
||||
export function getLegacyOpenAICompatProxyPid(): number | null {
|
||||
return readPid(getLegacyOpenAICompatProxyPidPath());
|
||||
}
|
||||
|
||||
export function writeOpenAICompatProxyPid(profileName: string, pid: number): void {
|
||||
ensureProxyDir();
|
||||
fs.writeFileSync(getOpenAICompatProxyPidPath(profileName), String(pid), 'utf8');
|
||||
@@ -44,16 +54,30 @@ export function removeOpenAICompatProxyPid(profileName: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function readOpenAICompatProxySession(profileName: string): OpenAICompatProxySession | null {
|
||||
export function removeLegacyOpenAICompatProxyPid(): void {
|
||||
try {
|
||||
return JSON.parse(
|
||||
fs.readFileSync(getOpenAICompatProxySessionPath(profileName), 'utf8')
|
||||
) as OpenAICompatProxySession;
|
||||
fs.unlinkSync(getLegacyOpenAICompatProxyPidPath());
|
||||
} catch {
|
||||
// Best-effort cleanup.
|
||||
}
|
||||
}
|
||||
|
||||
function readSession(sessionPath: string): OpenAICompatProxySession | null {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(sessionPath, 'utf8')) as OpenAICompatProxySession;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function readOpenAICompatProxySession(profileName: string): OpenAICompatProxySession | null {
|
||||
return readSession(getOpenAICompatProxySessionPath(profileName));
|
||||
}
|
||||
|
||||
export function readLegacyOpenAICompatProxySession(): OpenAICompatProxySession | null {
|
||||
return readSession(getLegacyOpenAICompatProxySessionPath());
|
||||
}
|
||||
|
||||
export function writeOpenAICompatProxySession(session: OpenAICompatProxySession): void {
|
||||
ensureProxyDir();
|
||||
fs.writeFileSync(
|
||||
@@ -71,6 +95,14 @@ export function removeOpenAICompatProxySession(profileName: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function removeLegacyOpenAICompatProxySession(): void {
|
||||
try {
|
||||
fs.unlinkSync(getLegacyOpenAICompatProxySessionPath());
|
||||
} catch {
|
||||
// Best-effort cleanup.
|
||||
}
|
||||
}
|
||||
|
||||
export function listOpenAICompatProxyProfileNames(): string[] {
|
||||
try {
|
||||
const entries = fs.readdirSync(getOpenAICompatProxyDir(), { withFileTypes: true });
|
||||
@@ -87,18 +119,6 @@ export function listOpenAICompatProxyProfileNames(): string[] {
|
||||
}
|
||||
}
|
||||
|
||||
export function cleanupLegacyOpenAICompatProxyState(): void {
|
||||
ensureProxyDir();
|
||||
const legacyNames = ['daemon.pid', 'session.json'];
|
||||
for (const legacyName of legacyNames) {
|
||||
try {
|
||||
fs.unlinkSync(path.join(getOpenAICompatProxyDir(), legacyName));
|
||||
} catch {
|
||||
// Best-effort cleanup.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveOpenAICompatProxyEntrypointCandidates(): string[] {
|
||||
const jsEntry = path.join(__dirname, 'proxy-daemon-entry.js');
|
||||
const tsEntry = path.join(__dirname, 'proxy-daemon-entry.ts');
|
||||
|
||||
+83
-20
@@ -12,10 +12,13 @@ import {
|
||||
getOpenAICompatProxyDir,
|
||||
} from './proxy-daemon-paths';
|
||||
import {
|
||||
getLegacyOpenAICompatProxyPid,
|
||||
getOpenAICompatProxyPid,
|
||||
listOpenAICompatProxyProfileNames,
|
||||
readLegacyOpenAICompatProxySession,
|
||||
readOpenAICompatProxySession,
|
||||
cleanupLegacyOpenAICompatProxyState,
|
||||
removeLegacyOpenAICompatProxyPid,
|
||||
removeLegacyOpenAICompatProxySession,
|
||||
removeOpenAICompatProxyPid,
|
||||
removeOpenAICompatProxySession,
|
||||
resolveOpenAICompatProxyEntrypointCandidates,
|
||||
@@ -23,7 +26,7 @@ import {
|
||||
writeOpenAICompatProxyPid,
|
||||
writeOpenAICompatProxySession,
|
||||
} from './proxy-daemon-state';
|
||||
import { resolveOpenAICompatProxyPreferredPort } from './proxy-port-resolver';
|
||||
import { resolveOpenAICompatProxyPortPreference } from './proxy-port-resolver';
|
||||
|
||||
export interface OpenAICompatProxyStatus extends Partial<OpenAICompatProxySession> {
|
||||
running: boolean;
|
||||
@@ -111,6 +114,12 @@ async function findOpenAICompatProxyPortNear(preferredPort: number): Promise<num
|
||||
return findOpenAICompatProxyPort();
|
||||
}
|
||||
|
||||
interface OpenAICompatProxyStateRecord {
|
||||
pid: number | null;
|
||||
session: OpenAICompatProxySession | null;
|
||||
source: 'profile' | 'legacy';
|
||||
}
|
||||
|
||||
async function resolveDaemonEntrypoint(): Promise<string | null> {
|
||||
for (const candidate of resolveOpenAICompatProxyEntrypointCandidates()) {
|
||||
try {
|
||||
@@ -157,20 +166,51 @@ export async function isOpenAICompatProxyRunning(port: number): Promise<boolean>
|
||||
async function getOpenAICompatProxyStatusForProfile(
|
||||
profileName: string
|
||||
): Promise<OpenAICompatProxyStatus> {
|
||||
const session = readOpenAICompatProxySession(profileName);
|
||||
const port = session?.port ?? OPENAI_COMPAT_PROXY_DEFAULT_PORT;
|
||||
const state = getOpenAICompatProxyStateForProfile(profileName);
|
||||
const session = state.session;
|
||||
if (!session) {
|
||||
return { running: false };
|
||||
}
|
||||
|
||||
const port = session.port;
|
||||
const running = await isOpenAICompatProxyRunning(port);
|
||||
return {
|
||||
running,
|
||||
pid: running ? getOpenAICompatProxyPid(profileName) || undefined : undefined,
|
||||
pid: running ? state.pid || undefined : undefined,
|
||||
...session,
|
||||
};
|
||||
}
|
||||
|
||||
function getOpenAICompatProxyStateForProfile(profileName: string): OpenAICompatProxyStateRecord {
|
||||
const session = readOpenAICompatProxySession(profileName);
|
||||
if (session) {
|
||||
return {
|
||||
pid: getOpenAICompatProxyPid(profileName),
|
||||
session,
|
||||
source: 'profile',
|
||||
};
|
||||
}
|
||||
|
||||
const legacySession = readLegacyOpenAICompatProxySession();
|
||||
if (legacySession?.profileName === profileName) {
|
||||
return {
|
||||
pid: getLegacyOpenAICompatProxyPid(),
|
||||
session: legacySession,
|
||||
source: 'legacy',
|
||||
};
|
||||
}
|
||||
|
||||
return { pid: null, session: null, source: 'profile' };
|
||||
}
|
||||
|
||||
export async function listOpenAICompatProxyStatuses(): Promise<OpenAICompatProxyStatus[]> {
|
||||
const profileNames = listOpenAICompatProxyProfileNames();
|
||||
const profileNames = new Set(listOpenAICompatProxyProfileNames());
|
||||
const legacySession = readLegacyOpenAICompatProxySession();
|
||||
if (legacySession?.profileName) {
|
||||
profileNames.add(legacySession.profileName);
|
||||
}
|
||||
const statuses = await Promise.all(
|
||||
profileNames.map((profileName) => getOpenAICompatProxyStatusForProfile(profileName))
|
||||
[...profileNames].map((profileName) => getOpenAICompatProxyStatusForProfile(profileName))
|
||||
);
|
||||
return statuses.filter((status) => status.profileName);
|
||||
}
|
||||
@@ -197,9 +237,10 @@ export async function getOpenAICompatProxyStatus(
|
||||
async function stopOpenAICompatProxyUnlocked(
|
||||
profileName: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const pid = getOpenAICompatProxyPid(profileName);
|
||||
const state = getOpenAICompatProxyStateForProfile(profileName);
|
||||
const pid = state.pid;
|
||||
if (!pid) {
|
||||
removeOpenAICompatProxySession(profileName);
|
||||
removeOpenAICompatProxyState(state, profileName);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -211,8 +252,7 @@ async function stopOpenAICompatProxyUnlocked(
|
||||
);
|
||||
|
||||
if (ownership === 'not-owned') {
|
||||
removeOpenAICompatProxyPid(profileName);
|
||||
removeOpenAICompatProxySession(profileName);
|
||||
removeOpenAICompatProxyState(state, profileName);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -224,8 +264,7 @@ async function stopOpenAICompatProxyUnlocked(
|
||||
}
|
||||
|
||||
if (ownership === 'not-running') {
|
||||
removeOpenAICompatProxyPid(profileName);
|
||||
removeOpenAICompatProxySession(profileName);
|
||||
removeOpenAICompatProxyState(state, profileName);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -256,16 +295,28 @@ async function stopOpenAICompatProxyUnlocked(
|
||||
}
|
||||
}
|
||||
|
||||
removeOpenAICompatProxyState(state, profileName);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
function removeOpenAICompatProxyState(
|
||||
state: OpenAICompatProxyStateRecord,
|
||||
profileName: string
|
||||
): void {
|
||||
if (state.source === 'legacy') {
|
||||
removeLegacyOpenAICompatProxyPid();
|
||||
removeLegacyOpenAICompatProxySession();
|
||||
return;
|
||||
}
|
||||
|
||||
removeOpenAICompatProxyPid(profileName);
|
||||
removeOpenAICompatProxySession(profileName);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function stopOpenAICompatProxy(
|
||||
profileName?: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
return withOpenAICompatProxyLock(async () => {
|
||||
cleanupLegacyOpenAICompatProxyState();
|
||||
if (profileName) {
|
||||
return stopOpenAICompatProxyUnlocked(profileName);
|
||||
}
|
||||
@@ -289,17 +340,22 @@ export async function startOpenAICompatProxy(
|
||||
options: { port?: number; host?: string; insecure?: boolean } = {}
|
||||
): Promise<StartOpenAICompatProxyResult> {
|
||||
return withOpenAICompatProxyLock(async () => {
|
||||
cleanupLegacyOpenAICompatProxyState();
|
||||
const status = await getOpenAICompatProxyStatus(profile.profileName);
|
||||
const host = options.host?.trim() || status.host || '127.0.0.1';
|
||||
const portPreference = resolveOpenAICompatProxyPortPreference(profile.profileName);
|
||||
const explicitPort = typeof options.port === 'number' ? options.port : undefined;
|
||||
const preferredPort =
|
||||
typeof options.port === 'number'
|
||||
? options.port
|
||||
: status.port || resolveOpenAICompatProxyPreferredPort(profile.profileName);
|
||||
explicitPort ??
|
||||
(portPreference.source === 'profile'
|
||||
? portPreference.port
|
||||
: status.port || portPreference.port);
|
||||
const requiresExactPort = explicitPort !== undefined || portPreference.source === 'profile';
|
||||
const port =
|
||||
status.running && status.port && (status.host || '127.0.0.1') === host
|
||||
? status.port
|
||||
: await findOpenAICompatProxyPortNear(preferredPort);
|
||||
: requiresExactPort
|
||||
? preferredPort
|
||||
: await findOpenAICompatProxyPortNear(preferredPort);
|
||||
if (port === 0) {
|
||||
return {
|
||||
success: false,
|
||||
@@ -319,6 +375,13 @@ export async function startOpenAICompatProxy(
|
||||
authToken: status.authToken,
|
||||
};
|
||||
}
|
||||
if (requiresExactPort && (await isPortOccupied(port))) {
|
||||
return {
|
||||
success: false,
|
||||
port,
|
||||
error: `Requested proxy port ${port} is already in use`,
|
||||
};
|
||||
}
|
||||
if (status.running) {
|
||||
const stopped = await stopOpenAICompatProxyUnlocked(profile.profileName);
|
||||
if (!stopped.success) {
|
||||
|
||||
@@ -1,11 +1,25 @@
|
||||
import { loadOrCreateUnifiedConfig } from '../config/unified-config-loader';
|
||||
import { OPENAI_COMPAT_PROXY_DEFAULT_PORT } from './proxy-daemon-paths';
|
||||
|
||||
export function resolveOpenAICompatProxyPreferredPort(profileName: string): number {
|
||||
export interface OpenAICompatProxyPortPreference {
|
||||
port: number;
|
||||
source: 'default' | 'profile';
|
||||
}
|
||||
|
||||
export function resolveOpenAICompatProxyPortPreference(
|
||||
profileName: string
|
||||
): OpenAICompatProxyPortPreference {
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
const profilePort = config.proxy?.profile_ports?.[profileName];
|
||||
if (typeof profilePort === 'number') {
|
||||
return profilePort;
|
||||
return { port: profilePort, source: 'profile' };
|
||||
}
|
||||
return config.proxy?.port ?? OPENAI_COMPAT_PROXY_DEFAULT_PORT;
|
||||
return {
|
||||
port: config.proxy?.port ?? OPENAI_COMPAT_PROXY_DEFAULT_PORT,
|
||||
source: 'default',
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveOpenAICompatProxyPreferredPort(profileName: string): number {
|
||||
return resolveOpenAICompatProxyPortPreference(profileName).port;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,11 @@ import {
|
||||
stopOpenAICompatProxy,
|
||||
} from '../../../src/proxy/proxy-daemon';
|
||||
import { resolveOpenAICompatProfileConfig } from '../../../src/proxy/profile-router';
|
||||
import {
|
||||
getLegacyOpenAICompatProxyPidPath,
|
||||
getLegacyOpenAICompatProxySessionPath,
|
||||
} from '../../../src/proxy/proxy-daemon-paths';
|
||||
import { mutateUnifiedConfig } from '../../../src/config/unified-config-loader';
|
||||
|
||||
let originalCcsHome: string | undefined;
|
||||
let tempDir: string;
|
||||
@@ -140,4 +145,162 @@ describe('openai proxy daemon lifecycle', () => {
|
||||
const secondHealth = await fetch(`http://127.0.0.1:${secondPort}/health`);
|
||||
expect(secondHealth.status).toBe(200);
|
||||
});
|
||||
|
||||
it('keeps a legacy singleton daemon visible across upgrade', async () => {
|
||||
const port = await getPort();
|
||||
const settingsPath = path.join(tempDir, 'legacy.settings.json');
|
||||
fs.writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify({
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434',
|
||||
ANTHROPIC_AUTH_TOKEN: 'ollama-legacy',
|
||||
ANTHROPIC_MODEL: 'qwen3-coder',
|
||||
CCS_DROID_PROVIDER: 'generic-chat-completion-api',
|
||||
},
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const profile = resolveOpenAICompatProfileConfig('legacy', settingsPath, {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434',
|
||||
ANTHROPIC_AUTH_TOKEN: 'ollama-legacy',
|
||||
ANTHROPIC_MODEL: 'qwen3-coder',
|
||||
CCS_DROID_PROVIDER: 'generic-chat-completion-api',
|
||||
});
|
||||
if (!profile) {
|
||||
throw new Error('Expected a legacy OpenAI-compatible profile');
|
||||
}
|
||||
|
||||
const started = await startOpenAICompatProxy(profile, { port });
|
||||
expect(started.success).toBe(true);
|
||||
expect(started.pid).toBeDefined();
|
||||
|
||||
const proxyDir = path.dirname(getLegacyOpenAICompatProxyPidPath());
|
||||
fs.writeFileSync(getLegacyOpenAICompatProxyPidPath(), String(started.pid), 'utf8');
|
||||
fs.writeFileSync(
|
||||
getLegacyOpenAICompatProxySessionPath(),
|
||||
JSON.stringify(
|
||||
{
|
||||
profileName: profile.profileName,
|
||||
settingsPath: profile.settingsPath,
|
||||
host: '127.0.0.1',
|
||||
port,
|
||||
baseUrl: profile.baseUrl,
|
||||
authToken: started.authToken,
|
||||
model: profile.model,
|
||||
},
|
||||
null,
|
||||
2
|
||||
) + '\n',
|
||||
'utf8'
|
||||
);
|
||||
fs.rmSync(path.join(proxyDir, 'legacy.daemon.pid'), { force: true });
|
||||
fs.rmSync(path.join(proxyDir, 'legacy.session.json'), { force: true });
|
||||
|
||||
const status = await getOpenAICompatProxyStatus('legacy');
|
||||
expect(status.running).toBe(true);
|
||||
expect(status.port).toBe(port);
|
||||
|
||||
const restarted = await startOpenAICompatProxy(profile);
|
||||
expect(restarted.success).toBe(true);
|
||||
expect(restarted.alreadyRunning).toBe(true);
|
||||
expect(restarted.port).toBe(port);
|
||||
|
||||
const stopped = await stopOpenAICompatProxy('legacy');
|
||||
expect(stopped.success).toBe(true);
|
||||
expect(fs.existsSync(getLegacyOpenAICompatProxyPidPath())).toBe(false);
|
||||
expect(fs.existsSync(getLegacyOpenAICompatProxySessionPath())).toBe(false);
|
||||
}, 35000);
|
||||
|
||||
it('fails when an explicit port is already occupied', async () => {
|
||||
const occupiedPort = await getPort();
|
||||
const server = Bun.serve({
|
||||
port: occupiedPort,
|
||||
hostname: '127.0.0.1',
|
||||
fetch: () => new Response('busy'),
|
||||
});
|
||||
|
||||
try {
|
||||
const settingsPath = path.join(tempDir, 'occupied-explicit.settings.json');
|
||||
fs.writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify({
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434',
|
||||
ANTHROPIC_AUTH_TOKEN: 'ollama-explicit',
|
||||
ANTHROPIC_MODEL: 'qwen3-coder',
|
||||
CCS_DROID_PROVIDER: 'generic-chat-completion-api',
|
||||
},
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const profile = resolveOpenAICompatProfileConfig('explicit', settingsPath, {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434',
|
||||
ANTHROPIC_AUTH_TOKEN: 'ollama-explicit',
|
||||
ANTHROPIC_MODEL: 'qwen3-coder',
|
||||
CCS_DROID_PROVIDER: 'generic-chat-completion-api',
|
||||
});
|
||||
if (!profile) {
|
||||
throw new Error('Expected an explicit-port OpenAI-compatible profile');
|
||||
}
|
||||
|
||||
const started = await startOpenAICompatProxy(profile, { port: occupiedPort });
|
||||
expect(started.success).toBe(false);
|
||||
expect(started.port).toBe(occupiedPort);
|
||||
expect(started.error).toContain(`Requested proxy port ${occupiedPort} is already in use`);
|
||||
} finally {
|
||||
server.stop(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('fails when a configured profile port is already occupied', async () => {
|
||||
const occupiedPort = await getPort();
|
||||
const server = Bun.serve({
|
||||
port: occupiedPort,
|
||||
hostname: '127.0.0.1',
|
||||
fetch: () => new Response('busy'),
|
||||
});
|
||||
|
||||
try {
|
||||
mutateUnifiedConfig((config) => {
|
||||
config.proxy = {
|
||||
...(config.proxy ?? {}),
|
||||
profile_ports: { mapped: occupiedPort },
|
||||
};
|
||||
});
|
||||
|
||||
const settingsPath = path.join(tempDir, 'occupied-mapped.settings.json');
|
||||
fs.writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify({
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434',
|
||||
ANTHROPIC_AUTH_TOKEN: 'ollama-mapped',
|
||||
ANTHROPIC_MODEL: 'qwen3-coder',
|
||||
CCS_DROID_PROVIDER: 'generic-chat-completion-api',
|
||||
},
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const profile = resolveOpenAICompatProfileConfig('mapped', settingsPath, {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434',
|
||||
ANTHROPIC_AUTH_TOKEN: 'ollama-mapped',
|
||||
ANTHROPIC_MODEL: 'qwen3-coder',
|
||||
CCS_DROID_PROVIDER: 'generic-chat-completion-api',
|
||||
});
|
||||
if (!profile) {
|
||||
throw new Error('Expected a mapped-port OpenAI-compatible profile');
|
||||
}
|
||||
|
||||
const started = await startOpenAICompatProxy(profile);
|
||||
expect(started.success).toBe(false);
|
||||
expect(started.port).toBe(occupiedPort);
|
||||
expect(started.error).toContain(`Requested proxy port ${occupiedPort} is already in use`);
|
||||
} finally {
|
||||
server.stop(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user