diff --git a/.eslintrc.js b/.eslintrc.js index 93842dca9..03eed33e6 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -221,6 +221,7 @@ module.exports = { '@typescript-eslint/no-unused-vars-experimental': 'off', '@typescript-eslint/dot-notation': 'off', '@typescript-eslint/no-unsafe-argument': 'off', + 'import/no-extraneous-dependencies': 'off', 'func-names': 'off', 'new-cap': 'off', 'no-shadow': 'off', diff --git a/package-lock.json b/package-lock.json index 0751df5a2..dabf9fc1c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -85,6 +85,7 @@ "eslint-plugin-import": "^2.29.1", "eslint-plugin-jsdoc": "^39.3.6", "eslint-plugin-no-only-tests": "2.6.0", + "get-port": "^5.1.1", "he": "^1.2.0", "madge": "^4.0.2", "marked": "^4.0.12", @@ -110,58 +111,6 @@ "vscode-jsonrpc": "^5.0.1" } }, - "../roku-deploy": { - "version": "3.12.0", - "extraneous": true, - "license": "MIT", - "dependencies": { - "chalk": "^2.4.2", - "dateformat": "^3.0.3", - "dayjs": "^1.11.0", - "fast-glob": "^3.2.12", - "fs-extra": "^7.0.1", - "is-glob": "^4.0.3", - "jsonc-parser": "^2.3.0", - "jszip": "^3.6.0", - "lodash": "^4.17.21", - "micromatch": "^4.0.4", - "moment": "^2.29.1", - "parse-ms": "^2.1.0", - "postman-request": "^2.88.1-postman.32", - "temp-dir": "^2.0.0", - "xml2js": "^0.5.0" - }, - "bin": { - "roku-deploy": "dist/cli.js" - }, - "devDependencies": { - "@types/chai": "^4.2.22", - "@types/fs-extra": "^5.0.1", - "@types/is-glob": "^4.0.2", - "@types/lodash": "^4.14.200", - "@types/micromatch": "^4.0.2", - "@types/mocha": "^9.0.0", - "@types/node": "^16.11.3", - "@types/q": "^1.5.8", - "@types/request": "^2.47.0", - "@types/sinon": "^10.0.4", - "@types/xml2js": "^0.4.5", - "@typescript-eslint/eslint-plugin": "5.1.0", - "@typescript-eslint/parser": "5.1.0", - "chai": "^4.3.4", - "coveralls-next": "^4.2.0", - "dedent": "^0.7.0", - "eslint": "8.0.1", - "mocha": "^9.1.3", - "nyc": "^15.1.0", - "q": "^1.5.1", - "rimraf": "^2.6.2", - "sinon": "^11.1.2", - "source-map-support": "^0.5.13", - "ts-node": "^10.3.1", - "typescript": "^4.4.4" - } - }, "node_modules/@babel/code-frame": { "version": "7.24.2", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.2.tgz", @@ -2109,9 +2058,9 @@ } }, "node_modules/aws4": { - "version": "1.13.0", - "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.0.tgz", - "integrity": "sha512-3AungXC4I8kKsS9PuS4JH2nc+0bVY/mjgrephHTIi8fpEeGsTHBUJeosp0Wc1myYMElmD0B3Oc4XL/HVJ4PV2g==" + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz", + "integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==" }, "node_modules/balanced-match": { "version": "1.0.0", @@ -2802,9 +2751,9 @@ } }, "node_modules/dayjs": { - "version": "1.11.12", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.12.tgz", - "integrity": "sha512-Rt2g+nTbLlDWZTwwrIXjy9MeiZmSDI375FvZs72ngxx8PDC6YXOeR3q5LAuPzjZQxhiWdRKac7RKV+YyQYfYIg==" + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", + "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==" }, "node_modules/debounce-promise": { "version": "3.1.2", @@ -4609,12 +4558,15 @@ } }, "node_modules/get-port": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/get-port/-/get-port-3.2.0.tgz", - "integrity": "sha1-3Xzn3hh8Bsi/NTeWrHHgmfCYDrw=", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/get-port/-/get-port-5.1.1.tgz", + "integrity": "sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==", "dev": true, "engines": { - "node": ">=4" + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/get-stream": { @@ -7359,13 +7311,13 @@ } }, "node_modules/postman-request": { - "version": "2.88.1-postman.34", - "resolved": "https://registry.npmjs.org/postman-request/-/postman-request-2.88.1-postman.34.tgz", - "integrity": "sha512-GkolJ4cIzgamcwHRDkeZc/taFWO1u2HuGNML47K9ZAsFH2LdEkS5Yy8QanpzhjydzV3WWthl9v60J8E7SjKodQ==", + "version": "2.88.1-postman.40", + "resolved": "https://registry.npmjs.org/postman-request/-/postman-request-2.88.1-postman.40.tgz", + "integrity": "sha512-uE4AiIqhjtHKp4pj9ei7fkdfNXEX9IqDBlK1plGAQne6y79UUlrTdtYLhwXoO0AMOvqyl9Ar+BU6Eo6P/MPgfg==", "dependencies": { "@postman/form-data": "~3.1.1", "@postman/tough-cookie": "~4.1.3-postman.1", - "@postman/tunnel-agent": "^0.6.3", + "@postman/tunnel-agent": "^0.6.4", "aws-sign2": "~0.7.0", "aws4": "^1.12.0", "brotli": "^1.3.3", @@ -7387,7 +7339,7 @@ "uuid": "^8.3.2" }, "engines": { - "node": ">= 6" + "node": ">= 16" } }, "node_modules/postman-request/node_modules/uuid": { @@ -8506,6 +8458,15 @@ "get-port": "^3.1.0" } }, + "node_modules/sync-rpc/node_modules/get-port": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/get-port/-/get-port-3.2.0.tgz", + "integrity": "sha512-x5UJKlgeUiNT8nyo/AcnwLnZuZNcSjSw0kogRB+Whd1fjjFq4B1hySFxSFWWSn4mIBzg3sRNUDFYc4g5gjPoLg==", + "dev": true, + "engines": { + "node": ">=4" + } + }, "node_modules/tapable": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.0.tgz", @@ -10815,9 +10776,9 @@ "integrity": "sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==" }, "aws4": { - "version": "1.13.0", - "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.0.tgz", - "integrity": "sha512-3AungXC4I8kKsS9PuS4JH2nc+0bVY/mjgrephHTIi8fpEeGsTHBUJeosp0Wc1myYMElmD0B3Oc4XL/HVJ4PV2g==" + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz", + "integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==" }, "balanced-match": { "version": "1.0.0", @@ -11331,9 +11292,9 @@ "integrity": "sha512-jyCETtSl3VMZMWeRo7iY1FL19ges1t55hMo5yaam4Jrsm5EPL89UQkoQRyiI+Yf4k8r2ZpdngkV8hr1lIdjb3Q==" }, "dayjs": { - "version": "1.11.12", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.12.tgz", - "integrity": "sha512-Rt2g+nTbLlDWZTwwrIXjy9MeiZmSDI375FvZs72ngxx8PDC6YXOeR3q5LAuPzjZQxhiWdRKac7RKV+YyQYfYIg==" + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", + "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==" }, "debounce-promise": { "version": "3.1.2", @@ -12680,9 +12641,9 @@ "dev": true }, "get-port": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/get-port/-/get-port-3.2.0.tgz", - "integrity": "sha1-3Xzn3hh8Bsi/NTeWrHHgmfCYDrw=", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/get-port/-/get-port-5.1.1.tgz", + "integrity": "sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==", "dev": true }, "get-stream": { @@ -14694,13 +14655,13 @@ } }, "postman-request": { - "version": "2.88.1-postman.34", - "resolved": "https://registry.npmjs.org/postman-request/-/postman-request-2.88.1-postman.34.tgz", - "integrity": "sha512-GkolJ4cIzgamcwHRDkeZc/taFWO1u2HuGNML47K9ZAsFH2LdEkS5Yy8QanpzhjydzV3WWthl9v60J8E7SjKodQ==", + "version": "2.88.1-postman.40", + "resolved": "https://registry.npmjs.org/postman-request/-/postman-request-2.88.1-postman.40.tgz", + "integrity": "sha512-uE4AiIqhjtHKp4pj9ei7fkdfNXEX9IqDBlK1plGAQne6y79UUlrTdtYLhwXoO0AMOvqyl9Ar+BU6Eo6P/MPgfg==", "requires": { "@postman/form-data": "~3.1.1", "@postman/tough-cookie": "~4.1.3-postman.1", - "@postman/tunnel-agent": "^0.6.3", + "@postman/tunnel-agent": "^0.6.4", "aws-sign2": "~0.7.0", "aws4": "^1.12.0", "brotli": "^1.3.3", @@ -15590,6 +15551,14 @@ "dev": true, "requires": { "get-port": "^3.1.0" + }, + "dependencies": { + "get-port": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/get-port/-/get-port-3.2.0.tgz", + "integrity": "sha512-x5UJKlgeUiNT8nyo/AcnwLnZuZNcSjSw0kogRB+Whd1fjjFq4B1hySFxSFWWSn4mIBzg3sRNUDFYc4g5gjPoLg==", + "dev": true + } } }, "tapable": { diff --git a/package.json b/package.json index 55f40e8fe..d8ad92ee2 100644 --- a/package.json +++ b/package.json @@ -108,6 +108,7 @@ "eslint-plugin-import": "^2.29.1", "eslint-plugin-jsdoc": "^39.3.6", "eslint-plugin-no-only-tests": "2.6.0", + "get-port": "^5.1.1", "he": "^1.2.0", "madge": "^4.0.2", "marked": "^4.0.12", diff --git a/src/lsp/ProjectManager.spec.ts b/src/lsp/ProjectManager.spec.ts index 045d3024f..2dc186e84 100644 --- a/src/lsp/ProjectManager.spec.ts +++ b/src/lsp/ProjectManager.spec.ts @@ -14,6 +14,11 @@ import { FileChangeType } from 'vscode-languageserver-protocol'; import { PathFilterer } from './PathFilterer'; import { Deferred } from '../deferred'; import type { DocumentActionWithStatus } from './DocumentManager'; +import * as net from 'net'; +import type { Program } from '../Program'; +import * as getPort from 'get-port'; + + const sinon = createSandbox(); describe('ProjectManager', () => { @@ -751,4 +756,110 @@ describe('ProjectManager', () => { expect(manager['standaloneProjects'].size).to.eql(0); }); }); + + it('completes promise when project is disposed in the middle of a flow', async function () { + this.timeout(20_000); + //small plugin to communicate over a socket inside the worker thread. + //This transpiles from tsc use `require()` for all imports and don't reference external vars + class Plugin { + public server: net.Server; + + private deferred = this.defer(); + + constructor(port: number, host: string) { + // eslint-disable-next-line + const net = require('net'); + console.log('Starting server'); + this.server = net.createServer((socket) => { + console.log('Client connected'); + socket.on('data', (data: Buffer) => { + let text = data.toString(); + console.log('message received', JSON.stringify(text)); + //when we get the event to resolve, do it + if (text === 'resolve') { + console.log('Resolving promise'); + this.deferred.resolve(); + this.server.close(); + } + }); + }); + this.server.listen(port, host); + } + + afterProgramCreate(program: Program) { + // hijack the function to get workspace symbols, return a promise that resolves in the future + program.getWorkspaceSymbols = () => { + return this.deferred.promise as any; + }; + } + + private defer() { + let resolve; + let reject; + let promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { + resolve: resolve, + reject: reject, + promise: promise + }; + } + } + + const port = await getPort(); + const host = '127.0.0.1'; + + //write a small brighterscript plugin to allow this test to communicate with the thread + fsExtra.outputFileSync(`${rootDir}/plugin.js`, ` + ${Plugin.toString()}; + exports.default = function() { + return new Plugin(${port}, "${host}"); + }; + `); + //write a bsconfig that will load this plugin + fsExtra.outputJsonSync(`${rootDir}/bsconfig.json`, { + plugins: [ + `${rootDir}/plugin.js` + ] + }); + + //wait for the projects to finish syncing/loading + await manager.syncProjects([{ + workspaceFolder: rootDir, + enableThreading: true + }]); + + //establish the connection with the plugin + const connection = net.createConnection({ + host: host, + port: port + }); + + //do the request to fetch symbols (this will be stalled on purpose by our test plugin) + let managerGetWorkspaceSymbolPromise = manager.getWorkspaceSymbol(); + + //small sleep to let things settle + await util.sleep(20); + + //now dispose the project (which should destroy all of the listeners) + manager['removeProject'](manager.projects[0]); + + //settle again + await util.sleep(20); + + console.log('Asking the client to resolve'); + + //resolve the request + connection.write('resolve'); + + //now wait to see if we ever get the response back + let result = await managerGetWorkspaceSymbolPromise; + + //the result should be an empty array, since the only project was rejected in the middle of the request + expect(result).to.eql([]); + + //test passes if the promise resolves + }); }); diff --git a/src/lsp/worker/MessageHandler.spec.ts b/src/lsp/worker/MessageHandler.spec.ts index 8f1937e5d..9d2a8a0af 100644 --- a/src/lsp/worker/MessageHandler.spec.ts +++ b/src/lsp/worker/MessageHandler.spec.ts @@ -2,6 +2,7 @@ import { MessageChannel } from 'worker_threads'; import { MessageHandler } from './MessageHandler'; import { expect } from '../../chai-config.spec'; import type { LspProject } from '../LspProject'; +import util from '../../util'; describe('MessageHandler', () => { let server: MessageHandler; @@ -38,4 +39,27 @@ describe('MessageHandler', () => { expect(error).to.exist; expect(error).instanceof(Error); }); + + it('terminates pending request promises when disposed', async () => { + let server = new MessageHandler({ + port: channel.port1, + onRequest: (request) => { + //never respond to any requests + } + }); + let client = new MessageHandler({ port: channel.port2 }); + let error: Error; + //send a request that will never be responded to + let responsePromise = client.sendRequest('activate'); + //sleep a bit to settle + await util.sleep(10); + server.dispose(); + client.dispose(); + try { + await responsePromise; + } catch (e) { + error = e as any; + } + expect(error?.message).to.eql('Request 0 has been rejected because MessageHandler is now disposed'); + }); }); diff --git a/src/lsp/worker/MessageHandler.ts b/src/lsp/worker/MessageHandler.ts index a3fbce894..c75235583 100644 --- a/src/lsp/worker/MessageHandler.ts +++ b/src/lsp/worker/MessageHandler.ts @@ -2,6 +2,7 @@ import type { MessagePort, parentPort } from 'worker_threads'; import * as EventEmitter from 'eventemitter3'; import type { DisposableLike } from '../../interfaces'; import util from '../../util'; +import { Deferred } from '../../deferred'; interface PseudoMessagePort { on: (name: 'message', cb: (message: any) => any) => any; @@ -53,17 +54,31 @@ export class MessageHandler> { private emitter = new EventEmitter(); + private activeRequests = new Map; + }>(); + /** * Get the response with this ID * @param id the ID of the response * @returns the message */ - private onResponse(id: number) { - return new Promise>((resolve) => { - this.emitter.once(`response-${id}`, (response) => { - resolve(response); - }); + private onResponse>(id: number): Promise { + const deferred = new Deferred(); + + //store this request so we can resolve it later, or reject if this class is disposed + this.activeRequests.set(id, { + id: id, + deferred: deferred + }); + + this.emitter.once(`response-${id}`, (response) => { + deferred.resolve(response); + this.activeRequests.delete(id); }); + + return deferred.promise; } /** @@ -148,6 +163,10 @@ export class MessageHandler> { public dispose() { util.applyDispose(this.disposables); + //reject all active requests + for (const request of this.activeRequests.values()) { + request.deferred.reject(new Error(`Request ${request.id} has been rejected because MessageHandler is now disposed`)); + } } }