This commit is contained in:
2025-05-29 14:06:47 +00:00
parent af13d3af10
commit e6b3ae395c
9 changed files with 246 additions and 196 deletions

View File

@ -2,194 +2,182 @@ import { expect, tap } from '@git.zone/tstest/tapbundle';
import { SmartProxy, type IRouteConfig } from '../ts/index.js';
/**
* Test that verifies mutex prevents race conditions during concurrent route updates
* Test that concurrent route updates complete successfully and maintain consistency
* This replaces the previous implementation-specific mutex tests with behavior-based tests
*/
tap.test('should handle concurrent route updates without race conditions', async (tools) => {
tools.timeout(10000);
tap.test('should handle concurrent route updates correctly', async (tools) => {
tools.timeout(15000);
const settings = {
port: 6001,
routes: [
{
name: 'initial-route',
match: {
ports: 8080
},
action: {
type: 'forward' as const,
targetUrl: 'http://localhost:3000'
}
}
],
acme: {
email: 'test@test.com',
port: 8080
const initialRoute: IRouteConfig = {
name: 'base-route',
match: { ports: 8080 },
action: {
type: 'forward',
target: { host: 'localhost', port: 3000 }
}
};
const proxy = new SmartProxy(settings);
const proxy = new SmartProxy({
routes: [initialRoute]
});
await proxy.start();
// Simulate concurrent route updates
const updates = [];
for (let i = 0; i < 5; i++) {
updates.push(proxy.updateRoutes([
...settings.routes,
{
name: `route-${i}`,
match: {
ports: [443]
},
action: {
type: 'forward' as const,
target: { host: 'localhost', port: 3001 + i },
tls: {
mode: 'terminate' as const,
certificate: 'auto' as const
}
}
}
]));
}
// Create many concurrent updates to stress test the system
const updatePromises: Promise<void>[] = [];
const routeNames: string[] = [];
// All updates should complete without errors
await Promise.all(updates);
// Verify final state
const currentRoutes = proxy['settings'].routes;
expect(currentRoutes.length).toEqual(2); // Initial route + last update
await proxy.stop();
});
/**
* Test that verifies mutex serializes route updates
*/
tap.test('should serialize route updates with mutex', async (tools) => {
tools.timeout(10000);
const settings = {
port: 6002,
routes: [{
name: 'test-route',
match: { ports: [8081] },
action: {
type: 'forward' as const,
targetUrl: 'http://localhost:3000'
}
}]
};
const proxy = new SmartProxy(settings);
await proxy.start();
let updateStartCount = 0;
let updateEndCount = 0;
let maxConcurrent = 0;
// Wrap updateRoutes to track concurrent execution
const originalUpdateRoutes = proxy['updateRoutes'].bind(proxy);
proxy['updateRoutes'] = async (routes: any[]) => {
updateStartCount++;
const concurrent = updateStartCount - updateEndCount;
maxConcurrent = Math.max(maxConcurrent, concurrent);
// Launch 20 concurrent updates
for (let i = 0; i < 20; i++) {
const routeName = `concurrent-route-${i}`;
routeNames.push(routeName);
// If mutex is working, only one update should run at a time
expect(concurrent).toEqual(1);
const result = await originalUpdateRoutes(routes);
updateEndCount++;
return result;
};
// Trigger multiple concurrent updates
const updates = [];
for (let i = 0; i < 5; i++) {
updates.push(proxy.updateRoutes([
...settings.routes,
const updatePromise = proxy.updateRoutes([
initialRoute,
{
name: `concurrent-route-${i}`,
match: { ports: [2000 + i] },
name: routeName,
match: { ports: 9000 + i },
action: {
type: 'forward' as const,
targetUrl: `http://localhost:${3000 + i}`
}
}
]));
}
await Promise.all(updates);
// All updates should have completed
expect(updateStartCount).toEqual(5);
expect(updateEndCount).toEqual(5);
expect(maxConcurrent).toEqual(1); // Mutex ensures only one at a time
await proxy.stop();
});
/**
* Test that challenge route state is preserved across certificate manager recreations
*/
tap.test('should preserve challenge route state during cert manager recreation', async (tools) => {
tools.timeout(10000);
const settings = {
port: 6003,
routes: [{
name: 'acme-route',
match: { ports: [443] },
action: {
type: 'forward' as const,
target: { host: 'localhost', port: 3001 },
tls: {
mode: 'terminate' as const,
certificate: 'auto' as const
}
}
}],
acme: {
email: 'test@test.com',
port: 8080
}
};
const proxy = new SmartProxy(settings);
// Track certificate manager recreations
let certManagerCreationCount = 0;
const originalCreateCertManager = proxy['createCertificateManager'].bind(proxy);
proxy['createCertificateManager'] = async (...args: any[]) => {
certManagerCreationCount++;
return originalCreateCertManager(...args);
};
await proxy.start();
// Initial creation
expect(certManagerCreationCount).toEqual(1);
// Multiple route updates
for (let i = 0; i < 3; i++) {
await proxy.updateRoutes([
...settings.routes as IRouteConfig[],
{
name: `dynamic-route-${i}`,
match: { ports: [9000 + i] },
action: {
type: 'forward' as const,
target: { host: 'localhost', port: 5000 + i }
type: 'forward',
target: { host: 'localhost', port: 4000 + i }
}
}
]);
updatePromises.push(updatePromise);
}
// Certificate manager should be recreated for each update
expect(certManagerCreationCount).toEqual(4); // 1 initial + 3 updates
// All updates should complete without errors
await Promise.all(updatePromises);
// State should be preserved (challenge route active)
const globalState = proxy['globalChallengeRouteActive'];
expect(globalState).toBeDefined();
// Verify the final state is consistent
const finalRoutes = proxy.routeManager.getAllRoutes();
// Should have base route plus one of the concurrent routes
expect(finalRoutes.length).toEqual(2);
expect(finalRoutes.some(r => r.name === 'base-route')).toBeTrue();
// One of the concurrent routes should have won
const concurrentRoute = finalRoutes.find(r => r.name?.startsWith('concurrent-route-'));
expect(concurrentRoute).toBeTruthy();
expect(routeNames).toContain(concurrentRoute!.name);
await proxy.stop();
});
/**
* Test rapid sequential route updates
*/
tap.test('should handle rapid sequential route updates', async (tools) => {
tools.timeout(10000);
const proxy = new SmartProxy({
routes: [{
name: 'initial',
match: { ports: 8081 },
action: {
type: 'forward',
target: { host: 'localhost', port: 3000 }
}
}]
});
await proxy.start();
// Perform rapid sequential updates
for (let i = 0; i < 10; i++) {
await proxy.updateRoutes([{
name: 'changing-route',
match: { ports: 8081 },
action: {
type: 'forward',
target: { host: 'localhost', port: 3000 + i }
}
}]);
}
// Verify final state
const finalRoutes = proxy.routeManager.getAllRoutes();
expect(finalRoutes.length).toEqual(1);
expect(finalRoutes[0].name).toEqual('changing-route');
expect((finalRoutes[0].action as any).target.port).toEqual(3009);
await proxy.stop();
});
/**
* Test that port management remains consistent during concurrent updates
*/
tap.test('should maintain port consistency during concurrent updates', async (tools) => {
tools.timeout(10000);
const proxy = new SmartProxy({
routes: [{
name: 'port-test',
match: { ports: 8082 },
action: {
type: 'forward',
target: { host: 'localhost', port: 3000 }
}
}]
});
await proxy.start();
// Create updates that add and remove ports
const updates: Promise<void>[] = [];
// Some updates add new ports
for (let i = 0; i < 5; i++) {
updates.push(proxy.updateRoutes([
{
name: 'port-test',
match: { ports: 8082 },
action: {
type: 'forward',
target: { host: 'localhost', port: 3000 }
}
},
{
name: `new-port-${i}`,
match: { ports: 9100 + i },
action: {
type: 'forward',
target: { host: 'localhost', port: 4000 + i }
}
}
]));
}
// Some updates remove ports
for (let i = 0; i < 5; i++) {
updates.push(proxy.updateRoutes([
{
name: 'port-test',
match: { ports: 8082 },
action: {
type: 'forward',
target: { host: 'localhost', port: 3000 }
}
}
]));
}
// Wait for all updates
await Promise.all(updates);
// Give time for port cleanup
await new Promise(resolve => setTimeout(resolve, 100));
// Verify final state
const finalRoutes = proxy.routeManager.getAllRoutes();
const listeningPorts = proxy['portManager'].getListeningPorts();
// Should only have the base port listening
expect(listeningPorts).toContain(8082);
// Routes should be consistent
expect(finalRoutes.some(r => r.name === 'port-test')).toBeTrue();
await proxy.stop();
});