fix: isolate concurrent OpenCode chat runs
This commit is contained in:
1 parent
7e8d9e3811
commit
65040730ec
29 files changed
+5207
-427
No files matched your search
@@ -2,11 +2,16 @@ import { EventEmitter } from 'node:events';
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { handleProviderRoutes } from '@electron/api/routes/providers';
|
||||
import { handleOpencodeRoutes } from '@electron/api/routes/opencode';
|
||||
import type { ProviderAccount } from '@electron/shared/providers/types';
|
||||
import {
|
||||
clearWorksSquareAIGatewayCredential,
|
||||
getWorksSquareAIGatewaySnapshot,
|
||||
} from '@electron/services/works-square-ai-gateway';
|
||||
import {
|
||||
isRuntimeConfigRefreshPending,
|
||||
withRuntimeConfigCoordinator,
|
||||
} from '@electron/opencode/runtime-config-readiness';
|
||||
|
||||
const providerServiceMock = vi.hoisted(() => ({
|
||||
getAccount: vi.fn(),
|
||||
@@ -164,6 +169,7 @@ describe('provider host api routes', () => {
|
||||
fallbackModels: ['gpt-4o-mini'],
|
||||
}),
|
||||
importedModels: ['gpt-4.1-mini', 'gpt-4o-mini'],
|
||||
runtimeRefreshRequired: true,
|
||||
});
|
||||
expect(JSON.stringify(response.json())).not.toContain('ws-ai-token');
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
@@ -173,6 +179,7 @@ describe('provider host api routes', () => {
|
||||
headers: {
|
||||
Authorization: 'Bearer access-token',
|
||||
},
|
||||
signal: expect.any(AbortSignal),
|
||||
},
|
||||
);
|
||||
expect(providerServiceMock.createAccount).toHaveBeenCalledWith(
|
||||
@@ -280,6 +287,7 @@ describe('provider host api routes', () => {
|
||||
expect(response.json()).toMatchObject({
|
||||
success: true,
|
||||
importedModels: ['deepseek-v4-pro', 'deepseek-chat'],
|
||||
runtimeRefreshRequired: false,
|
||||
});
|
||||
expect(providerServiceMock.createAccount).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -467,6 +475,71 @@ describe('provider host api routes', () => {
|
||||
expect(restart).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('defers a direct API-key rotation without restarting and reports the runtime refresh requirement', async () => {
|
||||
const existingAccount = createProviderAccount({
|
||||
id: 'niancode-user-models',
|
||||
vendorId: 'custom',
|
||||
label: 'Makelore Models',
|
||||
authMode: 'api_key',
|
||||
baseUrl: 'https://token.nianxx.cn/v1',
|
||||
apiProtocol: 'openai-completions',
|
||||
headers: {
|
||||
Authorization: 'Bearer {env:NIANCODE_OPENCODE_NIANCODE_USER_MODELS_API_KEY}',
|
||||
'X-Works-Square-AI-Token': '{env:NIANCODE_OPENCODE_NIANCODE_USER_MODELS_API_KEY}',
|
||||
},
|
||||
model: 'qwen3.7-max',
|
||||
fallbackModels: [],
|
||||
enabled: true,
|
||||
isDefault: true,
|
||||
metadata: {
|
||||
customModels: ['qwen3.7-max'],
|
||||
worksSquareCredentialMode: 'api_key',
|
||||
},
|
||||
});
|
||||
providerServiceMock.getAccount.mockResolvedValueOnce(existingAccount);
|
||||
providerServiceMock.getAccountApiKey.mockResolvedValueOnce('direct-key-k1');
|
||||
providerServiceMock.updateAccount.mockResolvedValueOnce(existingAccount);
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
provider_type: 'openai-compatible',
|
||||
label: 'Makelore Models',
|
||||
base_url: 'https://token.nianxx.cn/v1',
|
||||
api_key: 'direct-key-k2',
|
||||
credential_mode: 'api_key',
|
||||
api_key_expires_in: 3600,
|
||||
models: ['qwen3.7-max'],
|
||||
}), { status: 200 })));
|
||||
const response = createResponse();
|
||||
const restart = vi.fn();
|
||||
const manager = {
|
||||
getStatus: () => ({ state: 'running' as const, port: 4096 }),
|
||||
getRuntimeGeneration: () => 4,
|
||||
getRuntimeGenerationProvenance: () => 'fresh' as const,
|
||||
restart,
|
||||
};
|
||||
|
||||
await handleProviderRoutes(
|
||||
createRequest('POST', { accessToken: 'access-token', runtimeRefresh: 'defer' }),
|
||||
response.res,
|
||||
new URL('http://127.0.0.1/api/provider-accounts/import-user-model-config'),
|
||||
{
|
||||
opencodeManager: manager,
|
||||
} as never,
|
||||
);
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toMatchObject({
|
||||
success: true,
|
||||
runtimeRefreshRequired: true,
|
||||
});
|
||||
expect(providerServiceMock.updateAccount).toHaveBeenCalledWith(
|
||||
'niancode-user-models',
|
||||
expect.any(Object),
|
||||
'direct-key-k2',
|
||||
);
|
||||
expect(restart).not.toHaveBeenCalled();
|
||||
await expect(isRuntimeConfigRefreshPending(manager)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('returns a failure when the runtime restart fails after importing a new token', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({
|
||||
@@ -487,17 +560,18 @@ describe('provider host api routes', () => {
|
||||
providerServiceMock.getAccountApiKey.mockResolvedValueOnce('old-ws-ai-token');
|
||||
const restart = vi.fn().mockRejectedValue(new Error('runtime restart failed'));
|
||||
const response = createResponse();
|
||||
const manager = {
|
||||
getStatus: () => ({ state: 'running', port: 4096 }),
|
||||
getRuntimeGeneration: () => 1,
|
||||
getRuntimeGenerationProvenance: () => 'fresh' as const,
|
||||
restart,
|
||||
};
|
||||
|
||||
await handleProviderRoutes(
|
||||
createRequest('POST', { accessToken: 'access-token' }),
|
||||
response.res,
|
||||
new URL('http://127.0.0.1/api/provider-accounts/import-user-model-config'),
|
||||
{
|
||||
opencodeManager: {
|
||||
getStatus: () => ({ state: 'running', port: 4096 }),
|
||||
restart,
|
||||
},
|
||||
} as never,
|
||||
{ opencodeManager: manager } as never,
|
||||
);
|
||||
|
||||
expect(response.statusCode).toBe(500);
|
||||
@@ -511,6 +585,361 @@ describe('provider host api routes', () => {
|
||||
'host-api-token',
|
||||
);
|
||||
expect(restart).toHaveBeenCalledOnce();
|
||||
await expect(isRuntimeConfigRefreshPending(manager)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('retains a sticky latch when restart advances generation before rejecting', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
provider_type: 'openai-compatible',
|
||||
label: 'Makelore Models',
|
||||
base_url: 'https://one-api.example.com/v1',
|
||||
api_key: 'direct-key-k2',
|
||||
credential_mode: 'api_key',
|
||||
api_key_expires_in: 3600,
|
||||
models: ['gpt-4.1-mini'],
|
||||
}), { status: 200 })));
|
||||
providerServiceMock.getAccount.mockResolvedValueOnce(createProviderAccount({
|
||||
id: 'niancode-user-models',
|
||||
vendorId: 'custom',
|
||||
baseUrl: 'https://one-api.example.com/v1',
|
||||
model: 'old-model',
|
||||
}));
|
||||
providerServiceMock.getAccountApiKey.mockResolvedValueOnce('direct-key-k1');
|
||||
let generation = 1;
|
||||
const restart = vi.fn(async () => {
|
||||
generation = 2;
|
||||
throw new Error('fresh restart rejected');
|
||||
});
|
||||
const manager = {
|
||||
getStatus: () => ({ state: 'running', port: 4096 }),
|
||||
getRuntimeGeneration: () => generation,
|
||||
getRuntimeGenerationProvenance: () => 'fresh' as const,
|
||||
restart,
|
||||
};
|
||||
const response = createResponse();
|
||||
|
||||
await handleProviderRoutes(
|
||||
createRequest('POST', { accessToken: 'access-token', runtimeRefresh: 'apply' }),
|
||||
response.res,
|
||||
new URL('http://127.0.0.1/api/provider-accounts/import-user-model-config'),
|
||||
{ opencodeManager: manager } as never,
|
||||
);
|
||||
|
||||
expect(response.statusCode).toBe(500);
|
||||
await expect(isRuntimeConfigRefreshPending(manager)).resolves.toBe(true);
|
||||
generation = 3;
|
||||
await expect(isRuntimeConfigRefreshPending(manager)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('does not seed the local gateway before provider readiness can be established', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
provider_type: 'openai-compatible',
|
||||
label: 'Makelore Models',
|
||||
base_url: 'https://one-api.example.com/v1',
|
||||
api_key: 'gateway-token',
|
||||
credential_mode: 'works_square_ai_gateway',
|
||||
api_key_expires_in: 3600,
|
||||
models: ['gpt-4.1-mini'],
|
||||
}), { status: 200 })));
|
||||
providerServiceMock.getAccount.mockImplementationOnce(async () => await new Promise(() => undefined));
|
||||
const manager = {
|
||||
getStatus: () => ({ state: 'running', port: 4096 }),
|
||||
getRuntimeGeneration: () => 1,
|
||||
getRuntimeGenerationProvenance: () => 'fresh' as const,
|
||||
restart: vi.fn(),
|
||||
};
|
||||
const response = createResponse();
|
||||
try {
|
||||
const request = handleProviderRoutes(
|
||||
createRequest('POST', { accessToken: 'access-token', runtimeRefresh: 'defer' }),
|
||||
response.res,
|
||||
new URL('http://127.0.0.1/api/provider-accounts/import-user-model-config'),
|
||||
{ opencodeManager: manager } as never,
|
||||
);
|
||||
await vi.waitFor(() => expect(providerServiceMock.getAccount).toHaveBeenCalledOnce());
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
await expect(request).resolves.toBe(true);
|
||||
expect(response.statusCode).toBe(500);
|
||||
expect(getWorksSquareAIGatewaySnapshot()).toBeNull();
|
||||
await expect(isRuntimeConfigRefreshPending(manager)).resolves.toBe(false);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the active generation pending when selecting the imported default account fails', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
provider_type: 'openai-compatible',
|
||||
label: 'Makelore Models',
|
||||
base_url: 'https://one-api.example.com/v1',
|
||||
api_key: 'fresh-ws-ai-token',
|
||||
credential_mode: 'works_square_ai_gateway',
|
||||
api_key_expires_in: 3600,
|
||||
models: ['gpt-4.1-mini'],
|
||||
}), { status: 200 })));
|
||||
providerServiceMock.getAccount.mockResolvedValueOnce(createProviderAccount({
|
||||
id: 'niancode-user-models',
|
||||
model: 'old-model',
|
||||
}));
|
||||
providerServiceMock.getAccountApiKey.mockResolvedValueOnce('old-ws-ai-token');
|
||||
providerServiceMock.setDefaultAccount.mockRejectedValueOnce(new Error('default selection failed'));
|
||||
const manager = {
|
||||
getStatus: () => ({ state: 'running', port: 4096 }),
|
||||
getRuntimeGeneration: () => 1,
|
||||
getRuntimeGenerationProvenance: () => 'fresh' as const,
|
||||
restart: vi.fn(),
|
||||
};
|
||||
const response = createResponse();
|
||||
|
||||
await handleProviderRoutes(
|
||||
createRequest('POST', { accessToken: 'access-token' }),
|
||||
response.res,
|
||||
new URL('http://127.0.0.1/api/provider-accounts/import-user-model-config'),
|
||||
{ opencodeManager: manager } as never,
|
||||
);
|
||||
|
||||
expect(response.statusCode).toBe(500);
|
||||
expect(manager.restart).not.toHaveBeenCalled();
|
||||
await expect(isRuntimeConfigRefreshPending(manager)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('does not arm a stopped sticky latch when explicit apply persistence fails', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
provider_type: 'openai-compatible',
|
||||
label: 'Makelore Models',
|
||||
base_url: 'https://one-api.example.com/v1',
|
||||
api_key: 'direct-key-k2',
|
||||
credential_mode: 'api_key',
|
||||
api_key_expires_in: 3600,
|
||||
models: ['gpt-4.1-mini'],
|
||||
}), { status: 200 })));
|
||||
providerServiceMock.getAccount.mockResolvedValueOnce(createProviderAccount({
|
||||
id: 'niancode-user-models',
|
||||
vendorId: 'custom',
|
||||
baseUrl: 'https://one-api.example.com/v1',
|
||||
model: 'old-model',
|
||||
}));
|
||||
providerServiceMock.getAccountApiKey.mockResolvedValueOnce('direct-key-k1');
|
||||
providerServiceMock.setDefaultAccount.mockRejectedValueOnce(new Error('default persistence failed'));
|
||||
let generation = 1;
|
||||
const manager = {
|
||||
getStatus: () => ({ state: 'stopped' }),
|
||||
getRuntimeGeneration: () => generation,
|
||||
getRuntimeGenerationProvenance: () => 'fresh' as const,
|
||||
restart: vi.fn(),
|
||||
};
|
||||
await withRuntimeConfigCoordinator(manager, async (lease) => {
|
||||
lease.markRefreshPending();
|
||||
lease.retainRefreshPending();
|
||||
});
|
||||
const response = createResponse();
|
||||
|
||||
await handleProviderRoutes(
|
||||
createRequest('POST', { accessToken: 'access-token', runtimeRefresh: 'apply' }),
|
||||
response.res,
|
||||
new URL('http://127.0.0.1/api/provider-accounts/import-user-model-config'),
|
||||
{ opencodeManager: manager } as never,
|
||||
);
|
||||
|
||||
expect(response.statusCode).toBe(500);
|
||||
expect(manager.restart).not.toHaveBeenCalled();
|
||||
generation = 2;
|
||||
await expect(isRuntimeConfigRefreshPending(manager)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('applies an existing deferred latch even when the persisted provider values are unchanged', async () => {
|
||||
const payload = JSON.stringify({
|
||||
provider_type: 'openai-compatible',
|
||||
label: 'Makelore Models',
|
||||
base_url: 'https://one-api.example.com/v1',
|
||||
api_key: 'direct-key-k2',
|
||||
credential_mode: 'api_key',
|
||||
api_key_expires_in: 3600,
|
||||
models: ['gpt-4.1-mini'],
|
||||
});
|
||||
vi.stubGlobal('fetch', vi.fn()
|
||||
.mockResolvedValueOnce(new Response(payload, { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response(payload, { status: 200 })));
|
||||
let savedAccount = createProviderAccount({
|
||||
id: 'niancode-user-models',
|
||||
vendorId: 'custom',
|
||||
baseUrl: 'https://one-api.example.com/v1',
|
||||
model: 'old-model',
|
||||
});
|
||||
let savedApiKey = 'direct-key-k1';
|
||||
providerServiceMock.getAccount.mockImplementation(async () => savedAccount);
|
||||
providerServiceMock.getAccountApiKey.mockImplementation(async () => savedApiKey);
|
||||
providerServiceMock.updateAccount.mockImplementation(async (
|
||||
_id: string,
|
||||
account: ProviderAccount,
|
||||
apiKey: string,
|
||||
) => {
|
||||
savedAccount = account;
|
||||
savedApiKey = apiKey;
|
||||
return account;
|
||||
});
|
||||
let generation = 1;
|
||||
const restart = vi.fn(async () => {
|
||||
generation = 2;
|
||||
return { state: 'running', port: 4097, pid: 4242 };
|
||||
});
|
||||
const manager = {
|
||||
getStatus: () => ({ state: 'running', port: generation === 1 ? 4096 : 4097 }),
|
||||
getRuntimeGeneration: () => generation,
|
||||
getRuntimeGenerationProvenance: () => 'fresh' as const,
|
||||
restart,
|
||||
};
|
||||
|
||||
const deferred = createResponse();
|
||||
await handleProviderRoutes(
|
||||
createRequest('POST', { accessToken: 'access-token', runtimeRefresh: 'defer' }),
|
||||
deferred.res,
|
||||
new URL('http://127.0.0.1/api/provider-accounts/import-user-model-config'),
|
||||
{ opencodeManager: manager } as never,
|
||||
);
|
||||
expect(deferred.statusCode).toBe(200);
|
||||
expect(restart).not.toHaveBeenCalled();
|
||||
await expect(isRuntimeConfigRefreshPending(manager)).resolves.toBe(true);
|
||||
|
||||
const applied = createResponse();
|
||||
await handleProviderRoutes(
|
||||
createRequest('POST', { accessToken: 'access-token', runtimeRefresh: 'apply' }),
|
||||
applied.res,
|
||||
new URL('http://127.0.0.1/api/provider-accounts/import-user-model-config'),
|
||||
{ opencodeManager: manager } as never,
|
||||
);
|
||||
expect(applied.statusCode).toBe(200);
|
||||
expect(restart).toHaveBeenCalledOnce();
|
||||
await expect(isRuntimeConfigRefreshPending(manager)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('serializes manual restart behind provider persistence', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
provider_type: 'openai-compatible',
|
||||
label: 'Makelore Models',
|
||||
base_url: 'https://one-api.example.com/v1',
|
||||
api_key: 'direct-key-k2',
|
||||
credential_mode: 'api_key',
|
||||
api_key_expires_in: 3600,
|
||||
models: ['gpt-4.1-mini'],
|
||||
}), { status: 200 })));
|
||||
providerServiceMock.getAccount.mockResolvedValueOnce(createProviderAccount({
|
||||
id: 'niancode-user-models',
|
||||
vendorId: 'custom',
|
||||
baseUrl: 'https://one-api.example.com/v1',
|
||||
model: 'old-model',
|
||||
}));
|
||||
providerServiceMock.getAccountApiKey.mockResolvedValueOnce('direct-key-k1');
|
||||
let releasePersistence!: () => void;
|
||||
providerServiceMock.updateAccount.mockImplementationOnce(async () => await new Promise<ProviderAccount>((resolve) => {
|
||||
releasePersistence = () => resolve(createProviderAccount({ id: 'niancode-user-models' }));
|
||||
}));
|
||||
let generation = 1;
|
||||
const restart = vi.fn(async () => {
|
||||
generation = 2;
|
||||
return { state: 'running', port: 4097, pid: 4242 };
|
||||
});
|
||||
const manager = {
|
||||
getStatus: () => ({ state: 'running', port: 4096 }),
|
||||
getRuntimeGeneration: () => generation,
|
||||
getRuntimeGenerationProvenance: () => 'fresh' as const,
|
||||
restart,
|
||||
};
|
||||
const providerResponse = createResponse();
|
||||
const persistence = handleProviderRoutes(
|
||||
createRequest('POST', { accessToken: 'access-token', runtimeRefresh: 'defer' }),
|
||||
providerResponse.res,
|
||||
new URL('http://127.0.0.1/api/provider-accounts/import-user-model-config'),
|
||||
{ opencodeManager: manager } as never,
|
||||
);
|
||||
await vi.waitFor(() => expect(providerServiceMock.updateAccount).toHaveBeenCalledOnce());
|
||||
|
||||
const restartResponse = createResponse();
|
||||
const lifecycle = handleOpencodeRoutes(
|
||||
createRequest('POST'),
|
||||
restartResponse.res,
|
||||
new URL('http://127.0.0.1/api/opencode/restart'),
|
||||
{ opencodeManager: manager } as never,
|
||||
);
|
||||
await Promise.resolve();
|
||||
expect(restart).not.toHaveBeenCalled();
|
||||
|
||||
releasePersistence();
|
||||
await expect(persistence).resolves.toBe(true);
|
||||
await expect(lifecycle).resolves.toBe(true);
|
||||
expect(providerResponse.statusCode).toBe(200);
|
||||
expect(restartResponse.statusCode).toBe(200);
|
||||
expect(restart).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('revokes a timed-out provider persistence lease and suppresses its late continuation', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
provider_type: 'openai-compatible',
|
||||
label: 'Makelore Models',
|
||||
base_url: 'https://one-api.example.com/v1',
|
||||
api_key: 'direct-key-k2',
|
||||
credential_mode: 'api_key',
|
||||
api_key_expires_in: 3600,
|
||||
models: ['gpt-4.1-mini'],
|
||||
}), { status: 200 })));
|
||||
providerServiceMock.getAccount.mockResolvedValueOnce(createProviderAccount({
|
||||
id: 'niancode-user-models',
|
||||
vendorId: 'custom',
|
||||
baseUrl: 'https://one-api.example.com/v1',
|
||||
model: 'old-model',
|
||||
}));
|
||||
providerServiceMock.getAccountApiKey.mockResolvedValueOnce('direct-key-k1');
|
||||
let releasePersistence!: () => void;
|
||||
providerServiceMock.updateAccount.mockImplementationOnce(async () => await new Promise<ProviderAccount>((resolve) => {
|
||||
releasePersistence = () => resolve(createProviderAccount({ id: 'niancode-user-models' }));
|
||||
}));
|
||||
let timedOutGeneration = 1;
|
||||
const restart = vi.fn(async () => {
|
||||
timedOutGeneration = 2;
|
||||
return { state: 'running', port: 4097, pid: 4242 };
|
||||
});
|
||||
const manager = {
|
||||
getStatus: () => ({ state: 'running', port: 4096 }),
|
||||
getRuntimeGeneration: () => timedOutGeneration,
|
||||
getRuntimeGenerationProvenance: () => 'fresh' as const,
|
||||
restart,
|
||||
};
|
||||
const response = createResponse();
|
||||
try {
|
||||
const request = handleProviderRoutes(
|
||||
createRequest('POST', { accessToken: 'access-token', runtimeRefresh: 'defer' }),
|
||||
response.res,
|
||||
new URL('http://127.0.0.1/api/provider-accounts/import-user-model-config'),
|
||||
{ opencodeManager: manager } as never,
|
||||
);
|
||||
await vi.waitFor(() => expect(providerServiceMock.updateAccount).toHaveBeenCalledOnce());
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
await expect(request).resolves.toBe(true);
|
||||
expect(response.statusCode).toBe(500);
|
||||
expect(response.res.end).toHaveBeenCalledOnce();
|
||||
await expect(isRuntimeConfigRefreshPending(manager)).resolves.toBe(true);
|
||||
|
||||
const restartResponse = createResponse();
|
||||
await handleOpencodeRoutes(
|
||||
createRequest('POST'),
|
||||
restartResponse.res,
|
||||
new URL('http://127.0.0.1/api/opencode/restart'),
|
||||
{ opencodeManager: manager } as never,
|
||||
);
|
||||
expect(restartResponse.statusCode).toBe(200);
|
||||
expect(restart).toHaveBeenCalledOnce();
|
||||
await expect(isRuntimeConfigRefreshPending(manager)).resolves.toBe(true);
|
||||
|
||||
releasePersistence();
|
||||
await Promise.resolve();
|
||||
expect(providerServiceMock.setDefaultAccount).not.toHaveBeenCalled();
|
||||
expect(response.res.end).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
releasePersistence?.();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not accept an attached server as the new provider runtime', async () => {
|
||||
@@ -581,4 +1010,45 @@ describe('provider host api routes', () => {
|
||||
expect(providerServiceMock.createAccount).not.toHaveBeenCalled();
|
||||
expect(providerServiceMock.updateAccount).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('aborts a hung Works model-config fetch without acquiring the manager coordinator', async () => {
|
||||
vi.useFakeTimers();
|
||||
let fetchSignal: AbortSignal | undefined;
|
||||
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
|
||||
fetchSignal = init?.signal ?? undefined;
|
||||
return new Response(new ReadableStream<Uint8Array>({
|
||||
start() {
|
||||
// Headers arrive, but the response body intentionally never closes.
|
||||
},
|
||||
}), { status: 200 });
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const manager = {
|
||||
getStatus: () => ({ state: 'running', port: 4096 }),
|
||||
getRuntimeGeneration: () => 1,
|
||||
getRuntimeGenerationProvenance: () => 'fresh' as const,
|
||||
restart: vi.fn(),
|
||||
};
|
||||
const response = createResponse();
|
||||
try {
|
||||
const request = handleProviderRoutes(
|
||||
createRequest('POST', { accessToken: 'access-token', runtimeRefresh: 'defer' }),
|
||||
response.res,
|
||||
new URL('http://127.0.0.1/api/provider-accounts/import-user-model-config'),
|
||||
{ opencodeManager: manager } as never,
|
||||
);
|
||||
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledOnce());
|
||||
await expect(withRuntimeConfigCoordinator(manager, async () => 'unblocked')).resolves.toBe('unblocked');
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
await expect(request).resolves.toBe(true);
|
||||
expect(fetchSignal?.aborted).toBe(true);
|
||||
expect(response.statusCode).toBe(500);
|
||||
await expect(isRuntimeConfigRefreshPending(manager)).resolves.toBe(false);
|
||||
expect(providerServiceMock.createAccount).not.toHaveBeenCalled();
|
||||
expect(providerServiceMock.updateAccount).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user