Refactor VPN proxy components and update related behavior
This commit is contained in:
@@ -12,130 +12,113 @@ import { HarborError } from '../../dist/shared/errors.js';
|
||||
|
||||
const serverA = { id: 'a', label: 'Alpha', host: 'a.example', port: 1, protocol: 'vless' };
|
||||
const serverB = { id: 'b', label: 'Beta', host: 'b.example', port: 2, protocol: 'vless' };
|
||||
const duplicateA = { id: 'a2', label: 'Alpha', host: 'a2.example', port: 3, protocol: 'vless' };
|
||||
const rules = [{ type: 'domain_suffix', value: 'example', enabled: true }];
|
||||
|
||||
function createHarness(overrides = {}) {
|
||||
let state = structuredClone(overrides.state ?? {
|
||||
const profile = (id, label, desiredServerId = 'a', servers = [serverA, serverB]) => ({
|
||||
id,
|
||||
label,
|
||||
subscriptionUrl: `https://${id}.example/sub`,
|
||||
subscriptionConfig: { outbounds: [] },
|
||||
servers,
|
||||
userInfo: {},
|
||||
fetchedAt: '2026-08-08T10:00:00.000Z',
|
||||
desiredServerId,
|
||||
lastRefreshAttemptAt: null,
|
||||
lastRefreshErrorCode: null,
|
||||
});
|
||||
|
||||
function initialState() {
|
||||
return {
|
||||
revision: 5,
|
||||
servers: [serverA, serverB],
|
||||
selectedServerId: serverA.id,
|
||||
appliedServerId: serverA.id,
|
||||
profiles: [profile('personal', 'Личный'), profile('work', 'Работа', 'b')],
|
||||
desiredProfileId: 'personal',
|
||||
appliedProfileId: 'personal',
|
||||
appliedServerId: 'a',
|
||||
appliedServerSnapshot: serverA,
|
||||
routeRules: rules,
|
||||
appliedRouteRules: rules,
|
||||
routeRulesRevision: 1,
|
||||
connectionDesired: 'running',
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function createHarness(overrides = {}) {
|
||||
let state = structuredClone(overrides.state ?? initialState());
|
||||
let config = Object.hasOwn(overrides, 'config') ? overrides.config : 'old-config';
|
||||
let running = overrides.running ?? true;
|
||||
let tail = Promise.resolve();
|
||||
let active = 0;
|
||||
let peak = 0;
|
||||
let stateUpdateIndex = 0;
|
||||
let stateUpdates = 0;
|
||||
let startFailure = overrides.failStart || null;
|
||||
const events = [];
|
||||
const failures = {
|
||||
...overrides.failures,
|
||||
stateUpdates: [...(overrides.failures?.stateUpdates || [])],
|
||||
runtimeStarts: [...(overrides.failures?.runtimeStarts || [])],
|
||||
runtimeStops: [...(overrides.failures?.runtimeStops || [])],
|
||||
runtimeRestarts: [...(overrides.failures?.runtimeRestarts || [])],
|
||||
};
|
||||
|
||||
function applyState(mutator) {
|
||||
const failure = failures.stateUpdates[stateUpdateIndex++];
|
||||
if (failure instanceof Error) throw failure;
|
||||
const revision = state.revision + 1;
|
||||
state = { ...structuredClone(mutator(structuredClone(state))), revision };
|
||||
events.push(stateUpdateIndex === 1 ? 'state.desired' : stateUpdateIndex === 2 ? 'state.applied' : 'state.restore');
|
||||
if (failure?.after) throw failure.after;
|
||||
return structuredClone(state);
|
||||
}
|
||||
|
||||
const serialize = (operation) => {
|
||||
const run = async () => {
|
||||
active += 1;
|
||||
peak = Math.max(peak, active);
|
||||
try {
|
||||
return await operation();
|
||||
} finally {
|
||||
active -= 1;
|
||||
}
|
||||
try { return await operation(); } finally { active -= 1; }
|
||||
};
|
||||
const result = tail.then(run, run);
|
||||
tail = result.then(() => undefined, () => undefined);
|
||||
return result;
|
||||
};
|
||||
|
||||
const stopRuntime = async () => {
|
||||
events.push('runtime.stop');
|
||||
const failure = failures.runtimeStops.shift();
|
||||
if (failure) {
|
||||
const updateState = (mutator) => {
|
||||
stateUpdates += 1;
|
||||
const next = structuredClone(mutator(structuredClone(state)));
|
||||
state = { ...next, revision: state.revision + 1 };
|
||||
events.push('state.update');
|
||||
if (overrides.failStateAt === stateUpdates) throw new Error('state failed');
|
||||
return structuredClone(state);
|
||||
};
|
||||
|
||||
const start = async () => {
|
||||
events.push('runtime.start');
|
||||
if (startFailure) {
|
||||
const failure = startFailure;
|
||||
startFailure = null;
|
||||
running = false;
|
||||
throw failure;
|
||||
}
|
||||
if (overrides.startGate) await overrides.startGate();
|
||||
running = true;
|
||||
};
|
||||
const stop = async () => {
|
||||
events.push('runtime.stop');
|
||||
if (overrides.failStop) throw overrides.failStop;
|
||||
running = false;
|
||||
};
|
||||
|
||||
const restartRuntime = async () => {
|
||||
const restart = async () => {
|
||||
events.push('runtime.restart');
|
||||
const failure = failures.runtimeRestarts.shift();
|
||||
if (failure) {
|
||||
if (failure.code !== 'CONFIG_INVALID') running = false;
|
||||
throw failure;
|
||||
}
|
||||
if (overrides.failRestart) throw overrides.failRestart;
|
||||
running = true;
|
||||
};
|
||||
|
||||
const service = createConnectionService({
|
||||
state: {
|
||||
read: () => structuredClone(state),
|
||||
update: applyState,
|
||||
},
|
||||
subscription: {
|
||||
readConfig: () => overrides.missingSubscription ? null : { outbounds: [] },
|
||||
},
|
||||
state: { read: () => structuredClone(state), update: updateState },
|
||||
config: {
|
||||
exists: () => config !== null,
|
||||
build: (_subscription, selectedServerId, routeRules) => ({ selectedServerId, routeRules }),
|
||||
read: () => config,
|
||||
write: (value) => {
|
||||
events.push('config.write');
|
||||
if (failures.configWrite) throw failures.configWrite;
|
||||
config = JSON.stringify(value);
|
||||
if (failures.configWriteAfter) throw failures.configWriteAfter;
|
||||
},
|
||||
restore: (value) => {
|
||||
events.push('config.restore');
|
||||
if (failures.configRestore) throw failures.configRestore;
|
||||
config = value;
|
||||
},
|
||||
remove: () => {
|
||||
events.push('config.remove');
|
||||
if (failures.configRemove) throw failures.configRemove;
|
||||
config = null;
|
||||
if (overrides.failConfigWrite) throw overrides.failConfigWrite;
|
||||
},
|
||||
restore: (value) => { events.push('config.restore'); config = value; },
|
||||
remove: () => { events.push('config.remove'); config = null; },
|
||||
},
|
||||
runtime: {
|
||||
isRunning: async () => {
|
||||
if (failures.runtimeStatus) throw failures.runtimeStatus;
|
||||
if (overrides.failStatus) throw overrides.failStatus;
|
||||
return running;
|
||||
},
|
||||
start: async () => {
|
||||
events.push('runtime.start');
|
||||
const failure = failures.runtimeStarts.shift();
|
||||
if (failure) {
|
||||
running = false;
|
||||
throw failure;
|
||||
}
|
||||
if (overrides.startGate) await overrides.startGate();
|
||||
running = true;
|
||||
},
|
||||
stop: stopRuntime,
|
||||
stopCommand: () => captureRuntimeCommand(stopRuntime),
|
||||
restartCommand: () => captureRuntimeCommand(
|
||||
restartRuntime,
|
||||
{ preMutationErrorCodes: ['CONFIG_INVALID'] },
|
||||
),
|
||||
start,
|
||||
stop,
|
||||
stopCommand: () => captureRuntimeCommand(stop),
|
||||
restartCommand: () => captureRuntimeCommand(restart, { preMutationErrorCodes: ['CONFIG_INVALID'] }),
|
||||
},
|
||||
route: { isGatewayDirect: () => overrides.gatewayDirect === true },
|
||||
serialize,
|
||||
now: () => new Date('2026-08-08T12:00:00.000Z'),
|
||||
});
|
||||
@@ -143,344 +126,195 @@ function createHarness(overrides = {}) {
|
||||
return {
|
||||
service,
|
||||
events,
|
||||
enqueue: serialize,
|
||||
setState: (value) => { state = structuredClone(value); },
|
||||
serialize,
|
||||
snapshot: () => structuredClone({ state, config, running, peak }),
|
||||
};
|
||||
}
|
||||
|
||||
function assertDomainRestored(actual, expected) {
|
||||
const actualDomain = structuredClone(actual);
|
||||
const expectedDomain = structuredClone(expected);
|
||||
delete actualDomain.state.revision;
|
||||
delete expectedDomain.state.revision;
|
||||
delete actualDomain.peak;
|
||||
delete expectedDomain.peak;
|
||||
assert.deepEqual(actualDomain, expectedDomain);
|
||||
assert.ok(actual.state.revision >= expected.state.revision);
|
||||
function domain(value) {
|
||||
const copy = structuredClone(value);
|
||||
delete copy.state.revision;
|
||||
delete copy.peak;
|
||||
return copy;
|
||||
}
|
||||
|
||||
test('apply resolves ID inside the queue and commits desired, config, runtime, then applied state', async () => {
|
||||
const harness = createHarness({ state: {
|
||||
revision: 5,
|
||||
servers: [serverA, serverB, duplicateA],
|
||||
selectedServerId: serverA.id,
|
||||
appliedServerId: serverA.id,
|
||||
routeRules: rules,
|
||||
appliedRouteRules: rules,
|
||||
} });
|
||||
|
||||
assert.deepEqual(await harness.service.apply(' b ', 'Alpha'), { serverId: 'b', selectedTag: 'Beta' });
|
||||
const snapshot = harness.snapshot();
|
||||
assert.equal(snapshot.state.selectedServerId, 'b');
|
||||
assert.equal(snapshot.state.appliedServerId, 'b');
|
||||
assert.equal(snapshot.state.connectionDesired, 'running');
|
||||
assert.equal(snapshot.state.appliedAt, '2026-08-08T12:00:00.000Z');
|
||||
assert.deepEqual(snapshot.state.appliedRouteRules, rules);
|
||||
assert.deepEqual(harness.events, ['state.desired', 'config.write', 'runtime.start', 'state.applied']);
|
||||
|
||||
await assert.rejects(harness.service.apply('', 'Alpha'), (error) => error.code === 'SERVER_NOT_FOUND');
|
||||
await assert.rejects(harness.service.apply('missing', 'Beta'), (error) => error.code === 'SERVER_NOT_FOUND');
|
||||
});
|
||||
|
||||
test('apply accepts one legacy label and rejects missing config before mutation', async () => {
|
||||
const unique = createHarness();
|
||||
assert.deepEqual(await unique.service.apply('', ' Beta '), { serverId: 'b', selectedTag: 'Beta' });
|
||||
|
||||
const missing = createHarness({ missingSubscription: true });
|
||||
const before = missing.snapshot();
|
||||
await assert.rejects(missing.service.apply('b', ''), (error) => error.code === 'CONFIG_INVALID');
|
||||
assertDomainRestored(missing.snapshot(), before);
|
||||
assert.deepEqual(missing.events, []);
|
||||
});
|
||||
|
||||
test('apply restores previous running domain/config/runtime after mutation failures', async () => {
|
||||
const failureCases = [
|
||||
{ failures: { stateUpdates: [new Error('desired')] }, expected: 'desired' },
|
||||
{ failures: { configWriteAfter: new Error('config') }, expected: 'config' },
|
||||
{ failures: { runtimeStarts: [new Error('start')] }, expected: 'start' },
|
||||
{ failures: { stateUpdates: [null, { after: new Error('applied') }] }, expected: 'applied' },
|
||||
];
|
||||
|
||||
for (const { failures, expected } of failureCases) {
|
||||
const harness = createHarness({ failures });
|
||||
const before = harness.snapshot();
|
||||
await assert.rejects(harness.service.apply('b', ''), new RegExp(expected));
|
||||
assertDomainRestored(harness.snapshot(), before);
|
||||
}
|
||||
});
|
||||
|
||||
test('apply compensates a desired state update that persists and then throws', async () => {
|
||||
const failure = new Error('desired persisted then failed');
|
||||
const harness = createHarness({ failures: { stateUpdates: [{ after: failure }] } });
|
||||
const before = harness.snapshot();
|
||||
|
||||
await assert.rejects(harness.service.apply('b', ''), (error) => error === failure);
|
||||
test('apply validates the profile pair and publishes applied identity only after runtime succeeds', async () => {
|
||||
const harness = createHarness();
|
||||
assert.deepEqual(await harness.service.apply('work', 'a', '', 5), {
|
||||
profileId: 'work', serverId: 'a', selectedTag: 'Alpha',
|
||||
});
|
||||
const after = harness.snapshot();
|
||||
assertDomainRestored(after, before);
|
||||
assert.ok(after.state.revision > before.state.revision);
|
||||
assert.equal(after.state.desiredProfileId, 'work');
|
||||
assert.equal(after.state.profiles.find(({ id }) => id === 'work').desiredServerId, 'a');
|
||||
assert.equal(after.state.appliedProfileId, 'work');
|
||||
assert.equal(after.state.appliedServerId, 'a');
|
||||
assert.deepEqual(after.state.appliedServerSnapshot, serverA);
|
||||
assert.deepEqual(harness.events, ['config.write', 'runtime.start', 'state.update']);
|
||||
|
||||
await assert.rejects(harness.service.apply('personal', 'missing'), (error) => error.code === 'SERVER_NOT_FOUND');
|
||||
await assert.rejects(harness.service.apply('missing', 'a'), (error) => error.code === 'PROFILE_NOT_FOUND');
|
||||
await assert.rejects(harness.service.apply('personal', 'a', '', 4), (error) => error.code === 'STATE_CONFLICT');
|
||||
});
|
||||
|
||||
test('apply restores previous stopped state and prior config absence', async () => {
|
||||
const failure = new Error('start');
|
||||
const harness = createHarness({ config: null, running: false, failures: { runtimeStarts: [failure] } });
|
||||
const before = harness.snapshot();
|
||||
|
||||
await assert.rejects(harness.service.apply('b', ''), (error) => error === failure);
|
||||
assertDomainRestored(harness.snapshot(), before);
|
||||
assert.ok(harness.events.includes('config.remove'));
|
||||
assert.ok(harness.events.includes('runtime.stop'));
|
||||
});
|
||||
|
||||
test('apply rollback continues after restore failures and marks runtime rollback failure', async () => {
|
||||
const original = new Error('apply failed');
|
||||
const configFailure = new Error('config restore failed');
|
||||
const nonRuntime = createHarness({
|
||||
failures: { runtimeStarts: [original], configRestore: configFailure },
|
||||
});
|
||||
await assert.rejects(nonRuntime.service.apply('b', ''), (error) => {
|
||||
assert.ok(error instanceof AggregateError);
|
||||
assert.deepEqual(error.errors, [original, configFailure]);
|
||||
return true;
|
||||
});
|
||||
assert.ok(nonRuntime.events.filter((event) => event === 'runtime.start').length >= 2);
|
||||
assert.ok(nonRuntime.events.filter((event) => event.startsWith('state.')).length >= 2);
|
||||
|
||||
const rollbackFailure = new Error('runtime rollback failed');
|
||||
const runtimeBroken = createHarness({ failures: { runtimeStarts: [original, rollbackFailure] } });
|
||||
await assert.rejects(runtimeBroken.service.apply('b', ''), (error) => {
|
||||
assert.equal(error.code, 'PROCESS_START_FAILED');
|
||||
assert.deepEqual(error.cause.errors, [original, rollbackFailure]);
|
||||
return true;
|
||||
});
|
||||
assert.ok(runtimeBroken.events.filter((event) => event.startsWith('state.')).length >= 2);
|
||||
});
|
||||
|
||||
test('queued apply resolves latest state and concurrent valid applies remain serial', async () => {
|
||||
let release;
|
||||
const blocker = new Promise((resolve) => { release = resolve; });
|
||||
const stale = createHarness();
|
||||
const blocked = stale.enqueue(() => blocker);
|
||||
const apply = stale.service.apply('b', '');
|
||||
stale.setState({ ...stale.snapshot().state, servers: [serverA] });
|
||||
release();
|
||||
await blocked;
|
||||
await assert.rejects(apply, (error) => error.code === 'SERVER_NOT_FOUND');
|
||||
|
||||
const serial = createHarness();
|
||||
const [first, second] = await Promise.all([
|
||||
serial.service.apply('a', ''),
|
||||
serial.service.apply('b', ''),
|
||||
]);
|
||||
assert.equal(first.serverId, 'a');
|
||||
assert.equal(second.serverId, 'b');
|
||||
assert.equal(serial.snapshot().state.appliedServerId, 'b');
|
||||
assert.equal(serial.snapshot().peak, 1);
|
||||
});
|
||||
|
||||
test('server apply route preserves defaults, operation kind, response, and single ownership', async () => {
|
||||
const calls = [];
|
||||
const sent = [];
|
||||
const route = createServerApplyRoute({
|
||||
connection: {
|
||||
apply: async (serverId, selectedTag) => {
|
||||
calls.push([serverId, selectedTag]);
|
||||
return { serverId: 'resolved', selectedTag: 'Resolved' };
|
||||
},
|
||||
},
|
||||
readBody: async () => ({}),
|
||||
withOperation: async (kind, operation) => {
|
||||
calls.push(kind);
|
||||
return operation();
|
||||
},
|
||||
sendState: async (_res, extra) => { sent.push(extra); },
|
||||
});
|
||||
const response = {};
|
||||
|
||||
assert.equal(await route.handle({ method: 'GET', url: '/api/apply' }, response), false);
|
||||
assert.equal(await route.handle({ method: 'POST', url: '/api/apply' }, response), true);
|
||||
assert.deepEqual(calls, ['apply-server', ['', '']]);
|
||||
assert.deepEqual(sent, [{ serverId: 'resolved', selectedTag: 'Resolved' }]);
|
||||
|
||||
const source = readFileSync(new URL('../../src/server/index.ts', import.meta.url), 'utf8');
|
||||
assert.match(source, /createServerApplyRoute\(\{/);
|
||||
assert.doesNotMatch(source, /function applySelectedServer|req\.url === ['"]\/api\/apply['"]/);
|
||||
});
|
||||
|
||||
test('stop always cleans runtime and commits only desired stopped state', async () => {
|
||||
for (const running of [true, false]) {
|
||||
const harness = createHarness({ running });
|
||||
test('failed candidate config or runtime restores the old profile, config and running state', async () => {
|
||||
for (const failure of [
|
||||
{ failConfigWrite: new Error('write failed') },
|
||||
{ failStart: new Error('start failed') },
|
||||
{ failStateAt: 1 },
|
||||
]) {
|
||||
const harness = createHarness(failure);
|
||||
const before = harness.snapshot();
|
||||
await harness.service.stop();
|
||||
const after = harness.snapshot();
|
||||
assert.equal(after.running, false);
|
||||
assert.equal(after.state.connectionDesired, 'stopped');
|
||||
assert.equal(after.state.selectedServerId, before.state.selectedServerId);
|
||||
assert.equal(after.state.appliedServerId, before.state.appliedServerId);
|
||||
assert.deepEqual(after.state.appliedRouteRules, before.state.appliedRouteRules);
|
||||
assert.equal(harness.events.filter((event) => event === 'runtime.stop').length, 1);
|
||||
await assert.rejects(harness.service.apply('work', 'b'));
|
||||
assert.deepEqual(domain(harness.snapshot()), domain(before));
|
||||
}
|
||||
});
|
||||
|
||||
test('restart validates config inside the queue and commits applied runtime fields', async () => {
|
||||
const missing = createHarness({ config: null });
|
||||
const beforeMissing = missing.snapshot();
|
||||
await assert.rejects(missing.service.restart(), (error) => error.code === 'CONFIG_INVALID');
|
||||
assertDomainRestored(missing.snapshot(), beforeMissing);
|
||||
assert.deepEqual(missing.events, []);
|
||||
|
||||
for (const running of [true, false]) {
|
||||
const harness = createHarness({ running, state: {
|
||||
revision: 1,
|
||||
servers: [serverA, serverB],
|
||||
selectedServerId: serverB.id,
|
||||
appliedServerId: serverA.id,
|
||||
routeRules: rules,
|
||||
appliedRouteRules: [],
|
||||
appliedAt: 'preserved',
|
||||
connectionDesired: 'stopped',
|
||||
} });
|
||||
await harness.service.restart();
|
||||
const after = harness.snapshot();
|
||||
assert.equal(after.running, true);
|
||||
assert.equal(after.state.appliedServerId, serverB.id);
|
||||
assert.equal(after.state.connectionDesired, 'running');
|
||||
assert.deepEqual(after.state.appliedRouteRules, rules);
|
||||
assert.equal(after.state.appliedAt, 'preserved');
|
||||
}
|
||||
test('desired and applied targets stay old while candidate runtime is still starting', async () => {
|
||||
let entered;
|
||||
let release;
|
||||
const started = new Promise((resolve) => { entered = resolve; });
|
||||
const gate = new Promise((resolve) => { release = resolve; });
|
||||
const harness = createHarness({ startGate: async () => { entered(); await gate; } });
|
||||
const switching = harness.service.apply('work', 'b');
|
||||
await started;
|
||||
assert.equal(harness.snapshot().state.desiredProfileId, 'personal');
|
||||
assert.equal(harness.snapshot().state.appliedProfileId, 'personal');
|
||||
release();
|
||||
await switching;
|
||||
});
|
||||
|
||||
test('stop and restart compensate runtime and pre/post-write state failures', async () => {
|
||||
const statusFailure = new Error('status failed');
|
||||
for (const command of ['stop', 'restart']) {
|
||||
const status = createHarness({ failures: { runtimeStatus: statusFailure } });
|
||||
await status.service[command]();
|
||||
assert.ok(status.events.includes(`runtime.${command}`));
|
||||
}
|
||||
test('activate only changes desired while stopped and transactionally switches while running', async () => {
|
||||
const stopped = createHarness({ running: false });
|
||||
await stopped.service.activate('work');
|
||||
assert.equal(stopped.snapshot().state.desiredProfileId, 'work');
|
||||
assert.equal(stopped.snapshot().state.appliedProfileId, 'personal');
|
||||
assert.equal(stopped.events.includes('config.write'), false);
|
||||
|
||||
for (const stateFailure of [new Error('state before'), { after: new Error('state after') }]) {
|
||||
const stopHarness = createHarness({ failures: { stateUpdates: [stateFailure] } });
|
||||
const stopBefore = stopHarness.snapshot();
|
||||
await assert.rejects(stopHarness.service.stop(), /state/);
|
||||
assertDomainRestored(stopHarness.snapshot(), stopBefore);
|
||||
|
||||
const restartHarness = createHarness({
|
||||
running: false,
|
||||
failures: { stateUpdates: [stateFailure] },
|
||||
});
|
||||
const restartBefore = restartHarness.snapshot();
|
||||
await assert.rejects(restartHarness.service.restart(), /state/);
|
||||
assertDomainRestored(restartHarness.snapshot(), restartBefore);
|
||||
}
|
||||
|
||||
const stopFailure = new Error('stop failed');
|
||||
const stopped = createHarness({ failures: { runtimeStops: [stopFailure] } });
|
||||
const stoppedBefore = stopped.snapshot();
|
||||
await assert.rejects(stopped.service.stop(), (error) => error === stopFailure);
|
||||
assertDomainRestored(stopped.snapshot(), stoppedBefore);
|
||||
|
||||
const restartFailure = new Error('restart failed');
|
||||
const restarted = createHarness({ running: false, failures: { runtimeRestarts: [restartFailure] } });
|
||||
const restartedBefore = restarted.snapshot();
|
||||
await assert.rejects(restarted.service.restart(), (error) => error === restartFailure);
|
||||
assertDomainRestored(restarted.snapshot(), restartedBefore);
|
||||
const running = createHarness();
|
||||
await running.service.activate('work');
|
||||
assert.equal(running.snapshot().state.appliedProfileId, 'work');
|
||||
assert.equal(running.snapshot().state.appliedServerId, 'b');
|
||||
});
|
||||
|
||||
test('restart preserves CONFIG_INVALID when validation fails before runtime mutation', async () => {
|
||||
const invalid = new HarborError('CONFIG_INVALID');
|
||||
const harness = createHarness({ failures: { runtimeRestarts: [invalid] } });
|
||||
const before = harness.snapshot();
|
||||
|
||||
await assert.rejects(harness.service.restart(), (error) => error === invalid);
|
||||
assertDomainRestored(harness.snapshot(), before);
|
||||
assert.equal(harness.events.filter((event) => event === 'runtime.restart').length, 1);
|
||||
test('gateway-direct changes only local desired state and never claims a remote applied target', async () => {
|
||||
const harness = createHarness({ gatewayDirect: true });
|
||||
await harness.service.apply('work', 'b');
|
||||
const after = harness.snapshot();
|
||||
assert.equal(after.state.desiredProfileId, 'work');
|
||||
assert.equal(after.state.appliedProfileId, 'personal');
|
||||
assert.equal(harness.events.includes('config.write'), false);
|
||||
assert.equal(harness.events.includes('runtime.start'), false);
|
||||
});
|
||||
|
||||
test('runtime command adapter makes local and remote mutation phase explicit', async () => {
|
||||
const localFailure = new HarborError('CONFIG_INVALID');
|
||||
assert.deepEqual(
|
||||
await captureRuntimeCommand(
|
||||
async () => { throw localFailure; },
|
||||
{ preMutationErrorCodes: ['CONFIG_INVALID'] },
|
||||
),
|
||||
{ ok: false, mutationStarted: false, error: localFailure },
|
||||
);
|
||||
|
||||
const remoteFailure = new HarborError('PROCESS_START_FAILED');
|
||||
assert.deepEqual(
|
||||
await captureRuntimeCommand(async () => { throw remoteFailure; }),
|
||||
{ ok: false, mutationStarted: true, error: remoteFailure },
|
||||
);
|
||||
test('stop clears applied identity but preserves the desired pair', async () => {
|
||||
const harness = createHarness();
|
||||
await harness.service.stop();
|
||||
const after = harness.snapshot();
|
||||
assert.equal(after.running, false);
|
||||
assert.equal(after.state.connectionDesired, 'stopped');
|
||||
assert.equal(after.state.desiredProfileId, 'personal');
|
||||
assert.equal(after.state.profiles[0].desiredServerId, 'a');
|
||||
assert.equal(after.state.appliedProfileId, '');
|
||||
assert.equal(after.state.appliedServerId, '');
|
||||
assert.equal(after.state.appliedServerSnapshot, null);
|
||||
});
|
||||
|
||||
test('runtime command rollback continues through state errors and maps runtime restore failure', async () => {
|
||||
const original = new Error('stop state failed');
|
||||
const stateRestore = new Error('state restore failed');
|
||||
const aggregate = createHarness({
|
||||
failures: { stateUpdates: [{ after: original }, stateRestore] },
|
||||
});
|
||||
await assert.rejects(aggregate.service.stop(), (error) => {
|
||||
assert.ok(error instanceof AggregateError);
|
||||
assert.deepEqual(error.errors, [original, stateRestore]);
|
||||
return true;
|
||||
});
|
||||
assert.equal(aggregate.snapshot().running, true);
|
||||
test('restart uses applied pair while running and desired pair while stopped', async () => {
|
||||
const running = createHarness();
|
||||
await running.service.restart();
|
||||
assert.match(running.snapshot().config, /"selectedServerId":"a"/);
|
||||
|
||||
const stopFailure = new Error('stop failed');
|
||||
const runtimeRestore = new Error('start rollback failed');
|
||||
const broken = createHarness({
|
||||
failures: { runtimeStops: [stopFailure], runtimeStarts: [runtimeRestore] },
|
||||
});
|
||||
await assert.rejects(broken.service.stop(), (error) => {
|
||||
assert.equal(error.code, 'PROCESS_START_FAILED');
|
||||
assert.deepEqual(error.cause.errors, [stopFailure, runtimeRestore]);
|
||||
return true;
|
||||
});
|
||||
const stoppedState = initialState();
|
||||
stoppedState.desiredProfileId = 'work';
|
||||
stoppedState.connectionDesired = 'stopped';
|
||||
const stopped = createHarness({ running: false, state: stoppedState });
|
||||
await stopped.service.restart();
|
||||
assert.equal(stopped.snapshot().state.appliedProfileId, 'work');
|
||||
assert.equal(stopped.snapshot().state.appliedServerId, 'b');
|
||||
assert.match(stopped.snapshot().config, /"selectedServerId":"b"/);
|
||||
});
|
||||
|
||||
test('apply, stop, and restart share one deterministic connection queue', async () => {
|
||||
const applyThenStop = createHarness();
|
||||
await Promise.all([
|
||||
applyThenStop.service.apply('b', ''),
|
||||
applyThenStop.service.stop(),
|
||||
]);
|
||||
assert.equal(applyThenStop.snapshot().state.connectionDesired, 'stopped');
|
||||
assert.equal(applyThenStop.snapshot().peak, 1);
|
||||
|
||||
const stopThenRestart = createHarness();
|
||||
await Promise.all([
|
||||
stopThenRestart.service.stop(),
|
||||
stopThenRestart.service.restart(),
|
||||
]);
|
||||
assert.equal(stopThenRestart.snapshot().state.connectionDesired, 'running');
|
||||
assert.equal(stopThenRestart.snapshot().running, true);
|
||||
assert.equal(stopThenRestart.snapshot().peak, 1);
|
||||
test('running restart preserves a pending desired profile while restoring the applied pair', async () => {
|
||||
const state = initialState();
|
||||
state.desiredProfileId = 'work';
|
||||
const harness = createHarness({ state });
|
||||
await harness.service.restart();
|
||||
assert.equal(harness.snapshot().state.desiredProfileId, 'work');
|
||||
assert.equal(harness.snapshot().state.appliedProfileId, 'personal');
|
||||
});
|
||||
|
||||
test('connection runtime route preserves operation kinds, response extras, and single ownership', async () => {
|
||||
test('restart fails closed when runtime status is unknown', async () => {
|
||||
const state = initialState();
|
||||
state.desiredProfileId = 'work';
|
||||
const failure = new Error('status unavailable');
|
||||
const harness = createHarness({ state, failStatus: failure });
|
||||
const before = harness.snapshot();
|
||||
await assert.rejects(harness.service.restart(), (error) => error === failure);
|
||||
assert.deepEqual(domain(harness.snapshot()), domain(before));
|
||||
});
|
||||
|
||||
test('all connection mutations share one deterministic queue', async () => {
|
||||
let release;
|
||||
const gate = new Promise((resolve) => { release = resolve; });
|
||||
const harness = createHarness({ startGate: () => gate });
|
||||
const apply = harness.service.apply('work', 'b');
|
||||
const stop = harness.service.stop();
|
||||
release();
|
||||
await Promise.all([apply, stop]);
|
||||
assert.equal(harness.snapshot().peak, 1);
|
||||
assert.equal(harness.snapshot().state.connectionDesired, 'stopped');
|
||||
});
|
||||
|
||||
test('pair-aware apply and runtime routes preserve one HTTP owner', async () => {
|
||||
const calls = [];
|
||||
const sent = [];
|
||||
const route = createConnectionRuntimeRoute({
|
||||
const applyRoute = createServerApplyRoute({
|
||||
connection: {
|
||||
stop: async () => { calls.push('service.stop'); },
|
||||
restart: async () => { calls.push('service.restart'); },
|
||||
apply: async (...args) => {
|
||||
calls.push(args);
|
||||
return { profileId: 'personal', serverId: 'a', selectedTag: 'Alpha' };
|
||||
},
|
||||
},
|
||||
withOperation: async (kind, operation) => {
|
||||
calls.push(kind);
|
||||
return operation();
|
||||
readBody: async () => ({ profileId: 'personal', serverId: 'a', expectedRevision: 7 }),
|
||||
withOperation: async (kind, operation, options) => {
|
||||
calls.push([kind, options]);
|
||||
return operation(8);
|
||||
},
|
||||
sendState: async (_res, extra) => { sent.push(extra); },
|
||||
});
|
||||
const response = {};
|
||||
assert.equal(await applyRoute.handle({ method: 'POST', url: '/api/apply' }, {}), true);
|
||||
assert.deepEqual(calls, [
|
||||
['apply-server', { expectedRevision: 7, profileId: 'personal', serverId: 'a' }],
|
||||
['personal', 'a', '', 8],
|
||||
]);
|
||||
assert.deepEqual(sent, [{ profileId: 'personal', serverId: 'a', selectedTag: 'Alpha' }]);
|
||||
|
||||
assert.equal(await route.handle({ method: 'GET', url: '/api/singbox/stop' }, response), false);
|
||||
assert.equal(await route.handle({ method: 'POST', url: '/api/singbox/stop' }, response), true);
|
||||
assert.equal(await route.handle({ method: 'POST', url: '/api/singbox/restart' }, response), true);
|
||||
assert.deepEqual(calls, ['stop', 'service.stop', 'start', 'service.restart']);
|
||||
assert.deepEqual(sent, [{ singboxRunning: false }, { singboxRunning: true }]);
|
||||
const runtimeCalls = [];
|
||||
const runtimeRoute = createConnectionRuntimeRoute({
|
||||
connection: {
|
||||
stop: async () => { runtimeCalls.push('service.stop'); },
|
||||
restart: async () => { runtimeCalls.push('service.restart'); },
|
||||
},
|
||||
withOperation: async (kind, operation) => { runtimeCalls.push(kind); return operation(); },
|
||||
sendState: async () => {},
|
||||
});
|
||||
await runtimeRoute.handle({ method: 'POST', url: '/api/singbox/stop' }, {});
|
||||
await runtimeRoute.handle({ method: 'POST', url: '/api/singbox/restart' }, {});
|
||||
assert.deepEqual(runtimeCalls, ['stop', 'service.stop', 'start', 'service.restart']);
|
||||
|
||||
const source = readFileSync(new URL('../../src/server/index.ts', import.meta.url), 'utf8');
|
||||
assert.match(source, /createConnectionRuntimeRoute\(\{/);
|
||||
assert.doesNotMatch(source, /req\.url === ['"]\/api\/singbox\/(?:stop|restart)['"]/);
|
||||
assert.doesNotMatch(source, /function applySelectedServer|req\.url === ['"]\/api\/apply['"]/);
|
||||
});
|
||||
|
||||
test('runtime command adapter distinguishes failures before and after mutation', async () => {
|
||||
const invalid = new HarborError('CONFIG_INVALID');
|
||||
assert.deepEqual(
|
||||
await captureRuntimeCommand(async () => { throw invalid; }, { preMutationErrorCodes: ['CONFIG_INVALID'] }),
|
||||
{ ok: false, mutationStarted: false, error: invalid },
|
||||
);
|
||||
const failed = new Error('remote failed');
|
||||
assert.deepEqual(
|
||||
await captureRuntimeCommand(async () => { throw failed; }),
|
||||
{ ok: false, mutationStarted: true, error: failed },
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user