Skip to content

Commit

Permalink
server: rTorrent: manage request queue with p-queue - fix memory leak (
Browse files Browse the repository at this point in the history
…#650)

Our self-baked request queue doesn't work well in some cases, leading to memory leak and hung. 

Bug: #648
  • Loading branch information
trim21 committed Jul 3, 2023
1 parent 3b8a66c commit 2b652f8
Show file tree
Hide file tree
Showing 8 changed files with 2,715 additions and 343 deletions.
2,946 changes: 2,686 additions & 260 deletions package-lock.json

Large diffs are not rendered by default.

6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@
"prepack": "rm -rf dist && npm run build",
"start": "node --use_strict dist/index.js",
"start:development:client": "node client/scripts/development.js",
"start:development:server": "NODE_ENV=development TS_NODE_PROJECT=server/tsconfig.json ts-node-dev --transpile-only -r tsconfig-paths/register server/bin/start.ts",
"start:development:server": "NODE_ENV=development tsx watch server/bin/start.ts",
"start:test:rtorrent": "node scripts/testsetup.js",
"test": "jest --forceExit",
"test:watch": "jest --watchAll --forceExit",
Expand Down Expand Up @@ -137,6 +137,7 @@
"d3-selection": "^3.0.0",
"d3-shape": "^3.2.0",
"debug": "^4.3.4",
"esbuild-jest": "^0.5.0",
"eslint": "^8.42.0",
"eslint-config-airbnb": "^19.0.4",
"eslint-config-airbnb-typescript": "^17.0.0",
Expand Down Expand Up @@ -167,6 +168,7 @@
"morgan": "^1.10.0",
"overlayscrollbars": "^2.2.0",
"overlayscrollbars-react": "^0.5.0",
"p-queue": "^7.3.4",
"parse-torrent": "^9.1.5",
"passport": "^0.6.0",
"passport-jwt": "^4.0.1",
Expand Down Expand Up @@ -199,8 +201,8 @@
"tldts": "^5.7.112",
"ts-jest": "^28.0.8",
"ts-loader": "^9.4.3",
"ts-node-dev": "^2.0.0",
"tsconfig-paths": "^4.2.0",
"tsx": "^3.12.7",
"typed-emitter": "^2.1.0",
"typescript": "~4.9.5",
"webpack": "^5.86.0",
Expand Down
7 changes: 2 additions & 5 deletions server/.jest/auth.config.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
const {compilerOptions} = require('../tsconfig.json');
const {pathsToModuleNameMapper} = require('ts-jest');
const common = require('./common');

module.exports = {
...common,
displayName: 'auth',
preset: 'ts-jest/presets/js-with-babel',
rootDir: './../',
Expand All @@ -11,9 +13,4 @@ module.exports = {
testEnvironment: 'node',
testMatch: ['<rootDir>/routes/api/auth.test.ts'],
setupFilesAfterEnv: ['<rootDir>/.jest/auth.setup.js'],
globals: {
'ts-jest': {
isolatedModules: true,
},
},
};
12 changes: 12 additions & 0 deletions server/.jest/common.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
module.exports = {
transformIgnorePatterns: ['node_modules/(?!(p-queue|p-timeout)/)'],
transform: {
// transform ESM only package to CommonJS
'^.+\\.(t|j)sx?$': [
'esbuild-jest',
{
format: 'cjs',
},
],
},
};
7 changes: 2 additions & 5 deletions server/.jest/qbittorrent.config.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
const {compilerOptions} = require('../tsconfig.json');
const {pathsToModuleNameMapper} = require('ts-jest');
const common = require('./common');

module.exports = {
...common,
displayName: 'qbittorrent',
preset: 'ts-jest/presets/js-with-babel',
rootDir: './../',
Expand All @@ -11,9 +13,4 @@ module.exports = {
testEnvironment: 'node',
testPathIgnorePatterns: ['auth.test.ts'],
setupFilesAfterEnv: ['<rootDir>/.jest/qbittorrent.setup.js'],
globals: {
'ts-jest': {
isolatedModules: true,
},
},
};
7 changes: 2 additions & 5 deletions server/.jest/rtorrent.config.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
const {compilerOptions} = require('../tsconfig.json');
const {pathsToModuleNameMapper} = require('ts-jest');
const common = require('./common');

module.exports = {
...common,
displayName: 'rtorrent',
preset: 'ts-jest/presets/js-with-babel',
rootDir: './../',
Expand All @@ -11,9 +13,4 @@ module.exports = {
testEnvironment: 'node',
testPathIgnorePatterns: ['auth.test.ts'],
setupFilesAfterEnv: ['<rootDir>/.jest/rtorrent.setup.js'],
globals: {
'ts-jest': {
isolatedModules: true,
},
},
};
7 changes: 2 additions & 5 deletions server/.jest/transmission.config.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
const {compilerOptions} = require('../tsconfig.json');
const {pathsToModuleNameMapper} = require('ts-jest');
const common = require('./common');

module.exports = {
...common,
displayName: 'transmission',
preset: 'ts-jest/presets/js-with-babel',
rootDir: './../',
Expand All @@ -11,9 +13,4 @@ module.exports = {
testEnvironment: 'node',
testPathIgnorePatterns: ['auth.test.ts'],
setupFilesAfterEnv: ['<rootDir>/.jest/transmission.setup.js'],
globals: {
'ts-jest': {
isolatedModules: true,
},
},
};
66 changes: 5 additions & 61 deletions server/services/rTorrent/clientRequestManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,14 @@ import {methodCallJSON, methodCallXML} from './util/scgiUtil';
import {sanitizePath} from '../../util/fileUtil';

import type {MultiMethodCalls} from './util/rTorrentMethodCallUtil';
import PQueue from 'p-queue';

type MethodCallParameters = Array<string | Buffer | MultiMethodCalls>;

class ClientRequestManager {
queue: PQueue;
connectionSettings: RTorrentConnectionSettings;
isJSONCapable = false;
isRequestPending = false;
lastResponseTimestamp = 0;
pendingRequests: Array<{
methodName: string;
parameters: MethodCallParameters;
resolve: (value?: Record<string, string>) => void;
reject: (error?: NodeJS.ErrnoException) => void;
}> = [];

constructor(connectionSettings: RTorrentConnectionSettings) {
if (connectionSettings.type === 'socket') {
Expand All @@ -30,36 +24,10 @@ class ClientRequestManager {
} else {
this.connectionSettings = connectionSettings;
}
}

handleRequestEnd() {
this.isRequestPending = false;

// We avoid initiating any deferred requests until at least 250ms have
// since the previous response.
const currentTimestamp = Date.now();
const timeSinceLastResponse = currentTimestamp - this.lastResponseTimestamp;

if (timeSinceLastResponse <= 250) {
const delay = 250 - timeSinceLastResponse;
setTimeout(this.sendDeferredMethodCall, delay);
this.lastResponseTimestamp = currentTimestamp + delay;
} else {
this.sendDeferredMethodCall();
this.lastResponseTimestamp = currentTimestamp;
}
this.queue = new PQueue({autoStart: true, concurrency: 1});
}

sendDeferredMethodCall = () => {
const nextRequest = this.pendingRequests.shift();
if (nextRequest == null) {
return;
}

this.isRequestPending = true;
this.sendMethodCall(nextRequest.methodName, nextRequest.parameters).then(nextRequest.resolve, nextRequest.reject);
};

sendMethodCall = (methodName: string, parameters: MethodCallParameters) => {
const connectionOptions: NetConnectOpts =
this.connectionSettings.type === 'socket'
Expand All @@ -71,38 +39,14 @@ class ClientRequestManager {
port: this.connectionSettings.port,
};

const methodCall = this.isJSONCapable
return this.isJSONCapable
? methodCallJSON(connectionOptions, methodName, parameters)
: methodCallXML(connectionOptions, methodName, parameters);

return methodCall.then(
(response) => {
this.handleRequestEnd();
return response;
},
(error) => {
this.handleRequestEnd();
throw error;
},
);
};

methodCall = (methodName: string, parameters: MethodCallParameters) => {
// We only allow one request at a time.
if (this.isRequestPending) {
return new Promise(
(resolve: (value?: Record<string, string>) => void, reject: (error?: NodeJS.ErrnoException) => void) => {
this.pendingRequests.push({
methodName,
parameters,
resolve,
reject,
});
},
);
}
this.isRequestPending = true;
return this.sendMethodCall(methodName, parameters);
return this.queue.add(() => this.sendMethodCall(methodName, parameters));
};
}

Expand Down

0 comments on commit 2b652f8

Please sign in to comment.