Compare commits

...

3 Commits

Author SHA1 Message Date
renovate[bot]
ce16b90c3e Merge 8f07eab623 into dd759d9f0f 2025-07-10 14:24:14 +00:00
renovate[bot]
8f07eab623 chore(deps): pin dependencies 2025-07-10 14:24:09 +00:00
Pujit Mehrotra
dd759d9f0f fix(connect): rm eager restart on ERROR_RETYING connection status (#1502)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Improved connection handling to prevent unnecessary reconnection
attempts during error retry states, ensuring reconnections only occur on
specific failures.

* **Tests**
* Added comprehensive tests to verify connection recovery,
identity-based connection, logout behavior, DDoS prevention, and edge
case handling for connection state changes.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-10 10:21:54 -04:00
6 changed files with 298 additions and 72 deletions

View File

@@ -25,7 +25,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
node-version: '20.19.3'
- uses: pnpm/action-setup@v4
name: Install pnpm

View File

@@ -97,7 +97,7 @@
"lodash-es": "4.17.21",
"nest-authz": "2.17.0",
"rxjs": "7.8.2",
"ws": "^8.18.0",
"ws": "8.18.3",
"zen-observable-ts": "1.1.0"
}
}

View File

@@ -0,0 +1,269 @@
import { EventEmitter2 } from '@nestjs/event-emitter';
import { PubSub } from 'graphql-subscriptions';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { MinigraphStatus } from '../config/connect.config.js';
import { EVENTS, GRAPHQL_PUBSUB_CHANNEL } from '../helper/nest-tokens.js';
import { MothershipConnectionService } from '../mothership-proxy/connection.service.js';
import { MothershipController } from '../mothership-proxy/mothership.controller.js';
import { MothershipHandler } from '../mothership-proxy/mothership.events.js';
describe('MothershipHandler - Behavioral Tests', () => {
let handler: MothershipHandler;
let connectionService: MothershipConnectionService;
let mothershipController: MothershipController;
let pubSub: PubSub;
let eventEmitter: EventEmitter2;
// Track actual state changes and effects
let connectionAttempts: Array<{ timestamp: number; reason: string }> = [];
let publishedMessages: Array<{ channel: string; data: any }> = [];
let controllerStops: Array<{ timestamp: number; reason?: string }> = [];
beforeEach(() => {
// Reset tracking arrays
connectionAttempts = [];
publishedMessages = [];
controllerStops = [];
// Create real event emitter for integration testing
eventEmitter = new EventEmitter2();
// Mock connection service with realistic behavior
connectionService = {
getIdentityState: vi.fn(),
getConnectionState: vi.fn(),
} as any;
// Mock controller that tracks behavior instead of just method calls
mothershipController = {
initOrRestart: vi.fn().mockImplementation(() => {
connectionAttempts.push({
timestamp: Date.now(),
reason: 'initOrRestart called',
});
return Promise.resolve();
}),
stop: vi.fn().mockImplementation(() => {
controllerStops.push({
timestamp: Date.now(),
});
return Promise.resolve();
}),
} as any;
// Mock PubSub that tracks published messages
pubSub = {
publish: vi.fn().mockImplementation((channel: string, data: any) => {
publishedMessages.push({ channel, data });
return Promise.resolve();
}),
} as any;
handler = new MothershipHandler(connectionService, mothershipController, pubSub);
});
describe('Connection Recovery Behavior', () => {
it('should attempt reconnection when ping fails', async () => {
// Given: Connection is in ping failure state
vi.mocked(connectionService.getConnectionState).mockReturnValue({
status: MinigraphStatus.PING_FAILURE,
error: 'Ping timeout after 3 minutes',
});
// When: Connection status change event occurs
await handler.onMothershipConnectionStatusChanged();
// Then: System should attempt to recover the connection
expect(connectionAttempts).toHaveLength(1);
expect(connectionAttempts[0].reason).toBe('initOrRestart called');
});
it('should NOT interfere with exponential backoff during error retry state', async () => {
// Given: Connection is in error retry state (GraphQL client managing backoff)
vi.mocked(connectionService.getConnectionState).mockReturnValue({
status: MinigraphStatus.ERROR_RETRYING,
error: 'Network error',
timeout: 20000,
timeoutStart: Date.now(),
});
// When: Connection status change event occurs
await handler.onMothershipConnectionStatusChanged();
// Then: System should NOT interfere with ongoing retry logic
expect(connectionAttempts).toHaveLength(0);
});
it('should remain stable during normal connection states', async () => {
const stableStates = [MinigraphStatus.CONNECTED, MinigraphStatus.CONNECTING];
for (const status of stableStates) {
// Reset for each test
connectionAttempts.length = 0;
// Given: Connection is in a stable state
vi.mocked(connectionService.getConnectionState).mockReturnValue({
status,
error: null,
});
// When: Connection status change event occurs
await handler.onMothershipConnectionStatusChanged();
// Then: System should not trigger unnecessary reconnection attempts
expect(connectionAttempts).toHaveLength(0);
}
});
});
describe('Identity-Based Connection Behavior', () => {
it('should establish connection when valid API key becomes available', async () => {
// Given: Valid API key is present
vi.mocked(connectionService.getIdentityState).mockReturnValue({
state: {
apiKey: 'valid-unraid-key-12345',
unraidVersion: '6.12.0',
flashGuid: 'test-flash-guid',
apiVersion: '1.0.0',
},
isLoaded: true,
});
// When: Identity changes
await handler.onIdentityChanged();
// Then: System should establish mothership connection
expect(connectionAttempts).toHaveLength(1);
});
it('should not attempt connection without valid credentials', async () => {
const invalidCredentials = [{ apiKey: undefined }, { apiKey: '' }];
for (const credentials of invalidCredentials) {
// Reset for each test
connectionAttempts.length = 0;
// Given: Invalid or missing API key
vi.mocked(connectionService.getIdentityState).mockReturnValue({
state: credentials,
isLoaded: false,
});
// When: Identity changes
await handler.onIdentityChanged();
// Then: System should not attempt connection
expect(connectionAttempts).toHaveLength(0);
}
});
});
describe('Logout Behavior', () => {
it('should properly clean up connections and notify subscribers on logout', async () => {
// When: User logs out
await handler.logout({ reason: 'User initiated logout' });
// Then: System should clean up connections
expect(controllerStops).toHaveLength(1);
// And: Subscribers should be notified of empty state
expect(publishedMessages).toHaveLength(2);
const serversMessage = publishedMessages.find(
(m) => m.channel === GRAPHQL_PUBSUB_CHANNEL.SERVERS
);
const ownerMessage = publishedMessages.find(
(m) => m.channel === GRAPHQL_PUBSUB_CHANNEL.OWNER
);
expect(serversMessage?.data).toEqual({ servers: [] });
expect(ownerMessage?.data).toEqual({
owner: { username: 'root', url: '', avatar: '' },
});
});
it('should handle logout gracefully even without explicit reason', async () => {
// When: System logout occurs without reason
await handler.logout({});
// Then: Cleanup should still occur properly
expect(controllerStops).toHaveLength(1);
expect(publishedMessages).toHaveLength(2);
});
});
describe('DDoS Prevention Behavior', () => {
it('should demonstrate exponential backoff is respected during network errors', async () => {
// Given: Multiple rapid network errors occur
const errorStates = [
{ status: MinigraphStatus.ERROR_RETRYING, error: 'Network error 1' },
{ status: MinigraphStatus.ERROR_RETRYING, error: 'Network error 2' },
{ status: MinigraphStatus.ERROR_RETRYING, error: 'Network error 3' },
];
// When: Rapid error retry states occur
for (const state of errorStates) {
vi.mocked(connectionService.getConnectionState).mockReturnValue(state);
await handler.onMothershipConnectionStatusChanged();
}
// Then: No linear retry attempts should be made (respecting exponential backoff)
expect(connectionAttempts).toHaveLength(0);
});
it('should differentiate between network errors and ping failures', async () => {
// Given: Network error followed by ping failure
vi.mocked(connectionService.getConnectionState).mockReturnValue({
status: MinigraphStatus.ERROR_RETRYING,
error: 'Network error',
});
// When: Network error occurs
await handler.onMothershipConnectionStatusChanged();
// Then: No immediate reconnection attempt
expect(connectionAttempts).toHaveLength(0);
// Given: Ping failure occurs (different issue)
vi.mocked(connectionService.getConnectionState).mockReturnValue({
status: MinigraphStatus.PING_FAILURE,
error: 'Ping timeout',
});
// When: Ping failure occurs
await handler.onMothershipConnectionStatusChanged();
// Then: Immediate reconnection attempt should occur
expect(connectionAttempts).toHaveLength(1);
});
});
describe('Edge Cases and Error Handling', () => {
it('should handle missing connection state gracefully', async () => {
// Given: Connection service returns undefined
vi.mocked(connectionService.getConnectionState).mockReturnValue(undefined);
// When: Connection status change occurs
await handler.onMothershipConnectionStatusChanged();
// Then: No errors should occur, no reconnection attempts
expect(connectionAttempts).toHaveLength(0);
});
it('should handle malformed connection state', async () => {
// Given: Malformed connection state
vi.mocked(connectionService.getConnectionState).mockReturnValue({
status: 'UNKNOWN_STATUS' as any,
error: 'Malformed state',
});
// When: Connection status change occurs
await handler.onMothershipConnectionStatusChanged();
// Then: Should not trigger reconnection for unknown states
expect(connectionAttempts).toHaveLength(0);
});
});
});

View File

@@ -32,7 +32,7 @@ export class MothershipHandler {
const state = this.connectionService.getConnectionState();
if (
state &&
[MinigraphStatus.PING_FAILURE, MinigraphStatus.ERROR_RETRYING].includes(state.status)
[MinigraphStatus.PING_FAILURE].includes(state.status)
) {
this.logger.verbose(
'Mothership connection status changed to %s; setting up mothership subscription',

91
pnpm-lock.yaml generated
View File

@@ -913,7 +913,7 @@ importers:
version: 10.1.5(eslint@9.29.0(jiti@2.4.2))
eslint-plugin-import:
specifier: 2.31.0
version: 2.31.0(@typescript-eslint/parser@8.34.1(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.29.0(jiti@2.4.2))
version: 2.31.0(@typescript-eslint/parser@8.34.1(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.29.0(jiti@2.4.2))
eslint-plugin-no-relative-import-paths:
specifier: 1.6.1
version: 1.6.1
@@ -930,7 +930,7 @@ importers:
specifier: 18.0.0
version: 18.0.0
jiti:
specifier: ^2.4.2
specifier: 2.4.2
version: 2.4.2
postcss:
specifier: 8.5.6
@@ -984,7 +984,7 @@ importers:
specifier: 3.0.1
version: 3.0.1(typescript@5.8.3)
wrangler:
specifier: ^3.87.0
specifier: 3.114.10
version: 3.114.10
optionalDependencies:
'@rollup/rollup-linux-x64-gnu':
@@ -13538,7 +13538,7 @@ snapshots:
'@babel/traverse': 7.27.4
'@babel/types': 7.27.6
convert-source-map: 2.0.0
debug: 4.4.1
debug: 4.4.1(supports-color@5.5.0)
gensync: 1.0.0-beta.2
json5: 2.2.3
semver: 6.3.1
@@ -13879,7 +13879,7 @@ snapshots:
'@babel/parser': 7.27.5
'@babel/template': 7.27.2
'@babel/types': 7.27.6
debug: 4.4.1
debug: 4.4.1(supports-color@5.5.0)
globals: 11.12.0
transitivePeerDependencies:
- supports-color
@@ -14307,7 +14307,7 @@ snapshots:
'@eslint/config-array@0.20.1':
dependencies:
'@eslint/object-schema': 2.1.6
debug: 4.4.1
debug: 4.4.1(supports-color@5.5.0)
minimatch: 3.1.2
transitivePeerDependencies:
- supports-color
@@ -14352,7 +14352,7 @@ snapshots:
'@eslint/eslintrc@3.3.1':
dependencies:
ajv: 6.12.6
debug: 4.4.1
debug: 4.4.1(supports-color@5.5.0)
espree: 10.4.0
globals: 14.0.0
ignore: 5.3.2
@@ -17065,7 +17065,7 @@ snapshots:
'@typescript-eslint/types': 8.34.1
'@typescript-eslint/typescript-estree': 8.34.1(typescript@5.8.3)
'@typescript-eslint/visitor-keys': 8.34.1
debug: 4.4.1
debug: 4.4.1(supports-color@5.5.0)
eslint: 9.29.0(jiti@2.4.2)
typescript: 5.8.3
transitivePeerDependencies:
@@ -17075,7 +17075,7 @@ snapshots:
dependencies:
'@typescript-eslint/tsconfig-utils': 8.34.1(typescript@5.8.3)
'@typescript-eslint/types': 8.34.1
debug: 4.4.1
debug: 4.4.1(supports-color@5.5.0)
typescript: 5.8.3
transitivePeerDependencies:
- supports-color
@@ -17093,7 +17093,7 @@ snapshots:
dependencies:
'@typescript-eslint/typescript-estree': 8.34.1(typescript@5.8.3)
'@typescript-eslint/utils': 8.34.1(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3)
debug: 4.4.1
debug: 4.4.1(supports-color@5.5.0)
eslint: 9.29.0(jiti@2.4.2)
ts-api-utils: 2.1.0(typescript@5.8.3)
typescript: 5.8.3
@@ -17108,7 +17108,7 @@ snapshots:
'@typescript-eslint/tsconfig-utils': 8.34.1(typescript@5.8.3)
'@typescript-eslint/types': 8.34.1
'@typescript-eslint/visitor-keys': 8.34.1
debug: 4.4.1
debug: 4.4.1(supports-color@5.5.0)
fast-glob: 3.3.3
is-glob: 4.0.3
minimatch: 9.0.5
@@ -17281,7 +17281,7 @@ snapshots:
'@ampproject/remapping': 2.3.0
'@bcoe/v8-coverage': 1.0.2
ast-v8-to-istanbul: 0.3.3
debug: 4.4.1
debug: 4.4.1(supports-color@5.5.0)
istanbul-lib-coverage: 3.2.2
istanbul-lib-report: 3.0.1
istanbul-lib-source-maps: 5.0.6
@@ -19400,10 +19400,6 @@ snapshots:
dependencies:
ms: 2.1.3
debug@4.4.1:
dependencies:
ms: 2.1.3
debug@4.4.1(supports-color@5.5.0):
dependencies:
ms: 2.1.3
@@ -19969,7 +19965,7 @@ snapshots:
esbuild-register@3.6.0(esbuild@0.25.5):
dependencies:
debug: 4.4.1
debug: 4.4.1(supports-color@5.5.0)
esbuild: 0.25.5
transitivePeerDependencies:
- supports-color
@@ -20172,16 +20168,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-module-utils@2.12.0(@typescript-eslint/parser@8.34.1(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint@9.29.0(jiti@2.4.2)):
dependencies:
debug: 3.2.7
optionalDependencies:
'@typescript-eslint/parser': 8.34.1(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3)
eslint: 9.29.0(jiti@2.4.2)
eslint-import-resolver-node: 0.3.9
transitivePeerDependencies:
- supports-color
eslint-plugin-es-x@7.8.0(eslint@9.29.0(jiti@2.4.2)):
dependencies:
'@eslint-community/eslint-utils': 4.7.0(eslint@9.29.0(jiti@2.4.2))
@@ -20236,35 +20222,6 @@ snapshots:
- eslint-import-resolver-webpack
- supports-color
eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.34.1(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.29.0(jiti@2.4.2)):
dependencies:
'@rtsao/scc': 1.1.0
array-includes: 3.1.8
array.prototype.findlastindex: 1.2.5
array.prototype.flat: 1.3.3
array.prototype.flatmap: 1.3.3
debug: 3.2.7
doctrine: 2.1.0
eslint: 9.29.0(jiti@2.4.2)
eslint-import-resolver-node: 0.3.9
eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.34.1(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint@9.29.0(jiti@2.4.2))
hasown: 2.0.2
is-core-module: 2.16.1
is-glob: 4.0.3
minimatch: 3.1.2
object.fromentries: 2.0.8
object.groupby: 1.0.3
object.values: 1.2.1
semver: 6.3.1
string.prototype.trimend: 1.0.9
tsconfig-paths: 3.15.0
optionalDependencies:
'@typescript-eslint/parser': 8.34.1(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3)
transitivePeerDependencies:
- eslint-import-resolver-typescript
- eslint-import-resolver-webpack
- supports-color
eslint-plugin-jsdoc@50.8.0(eslint@9.29.0(jiti@2.4.2)):
dependencies:
'@es-joy/jsdoccomment': 0.50.2
@@ -20406,7 +20363,7 @@ snapshots:
ajv: 6.12.6
chalk: 4.1.2
cross-spawn: 7.0.6
debug: 4.4.1
debug: 4.4.1(supports-color@5.5.0)
escape-string-regexp: 4.0.0
eslint-scope: 8.4.0
eslint-visitor-keys: 4.2.1
@@ -21508,7 +21465,7 @@ snapshots:
http-proxy-agent@7.0.2:
dependencies:
agent-base: 7.1.3
debug: 4.4.1
debug: 4.4.1(supports-color@5.5.0)
transitivePeerDependencies:
- supports-color
@@ -21555,7 +21512,7 @@ snapshots:
https-proxy-agent@7.0.6:
dependencies:
agent-base: 7.1.3
debug: 4.4.1
debug: 4.4.1(supports-color@5.5.0)
transitivePeerDependencies:
- supports-color
@@ -22039,7 +21996,7 @@ snapshots:
istanbul-lib-source-maps@5.0.6:
dependencies:
'@jridgewell/trace-mapping': 0.3.25
debug: 4.4.1
debug: 4.4.1(supports-color@5.5.0)
istanbul-lib-coverage: 3.2.2
transitivePeerDependencies:
- supports-color
@@ -24057,7 +24014,7 @@ snapshots:
postcss-styl@0.12.3:
dependencies:
debug: 4.4.1
debug: 4.4.1(supports-color@5.5.0)
fast-diff: 1.3.0
lodash.sortedlastindex: 4.1.0
postcss: 8.5.6
@@ -25434,7 +25391,7 @@ snapshots:
stylus@0.57.0:
dependencies:
css: 3.0.0
debug: 4.4.1
debug: 4.4.1(supports-color@5.5.0)
glob: 7.2.3
safer-buffer: 2.1.2
sax: 1.2.4
@@ -26255,7 +26212,7 @@ snapshots:
vite-node@3.2.4(@types/node@22.15.32)(jiti@2.4.2)(stylus@0.57.0)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0):
dependencies:
cac: 6.7.14
debug: 4.4.1
debug: 4.4.1(supports-color@5.5.0)
es-module-lexer: 1.7.0
pathe: 2.0.3
vite: 7.0.3(@types/node@22.15.32)(jiti@2.4.2)(stylus@0.57.0)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0)
@@ -26297,7 +26254,7 @@ snapshots:
'@microsoft/api-extractor': 7.43.0(@types/node@22.15.32)
'@rollup/pluginutils': 5.2.0(rollup@4.44.0)
'@vue/language-core': 1.8.27(typescript@5.8.3)
debug: 4.4.1
debug: 4.4.1(supports-color@5.5.0)
kolorist: 1.8.0
magic-string: 0.30.17
typescript: 5.8.3
@@ -26313,7 +26270,7 @@ snapshots:
dependencies:
'@antfu/utils': 0.7.10
'@rollup/pluginutils': 5.2.0(rollup@4.44.0)
debug: 4.4.1
debug: 4.4.1(supports-color@5.5.0)
error-stack-parser-es: 0.1.5
fs-extra: 11.3.0
open: 10.1.2
@@ -26549,7 +26506,7 @@ snapshots:
'@vitest/spy': 3.2.4
'@vitest/utils': 3.2.4
chai: 5.2.0
debug: 4.4.1
debug: 4.4.1(supports-color@5.5.0)
expect-type: 1.2.1
magic-string: 0.30.17
pathe: 2.0.3
@@ -26634,7 +26591,7 @@ snapshots:
vue-eslint-parser@10.1.3(eslint@9.29.0(jiti@2.4.2)):
dependencies:
debug: 4.4.1
debug: 4.4.1(supports-color@5.5.0)
eslint: 9.29.0(jiti@2.4.2)
eslint-scope: 8.4.0
eslint-visitor-keys: 4.2.1

View File

@@ -94,7 +94,7 @@
"eslint-plugin-storybook": "9.0.16",
"eslint-plugin-vue": "10.2.0",
"happy-dom": "18.0.0",
"jiti": "^2.4.2",
"jiti": "2.4.2",
"postcss": "8.5.6",
"postcss-import": "16.1.1",
"prettier": "3.5.3",
@@ -112,7 +112,7 @@
"vitest": "3.2.4",
"vue": "3.5.17",
"vue-tsc": "3.0.1",
"wrangler": "^3.87.0"
"wrangler": "3.114.10"
},
"optionalDependencies": {
"@rollup/rollup-linux-x64-gnu": "4.44.0"