diff --git a/package.json b/package.json index af3d61b5105..70bbee400f7 100644 --- a/package.json +++ b/package.json @@ -1523,6 +1523,14 @@ "default": 10000, "description": "%jupyter.configuration.jupyter.jupyterInterruptTimeout.description%" }, + "jupyter.kernels.portRange.startPort": { + "type": "number", + "default": 9000, + "minimum": 1024, + "maximum": 65535, + "description": "%jupyter.configuration.jupyter.kernels.portRange.startPort.description%", + "scope": "machine" + }, "jupyter.interactiveWindow.textEditor.executeSelection": { "type": "boolean", "default": false, diff --git a/package.nls.json b/package.nls.json index a499a01d894..75f640c5b20 100644 --- a/package.nls.json +++ b/package.nls.json @@ -150,6 +150,7 @@ "jupyter.configuration.jupyter.notebookFileRoot.description": "Set the root directory for Jupyter Notebooks and the Interactive Window running locally. \n\n**Note:** This does not apply to Remote Jupyter Kernels.", "jupyter.configuration.jupyter.useDefaultConfigForJupyter.description": "When running Jupyter locally, create a default empty Jupyter config", "jupyter.configuration.jupyter.jupyterInterruptTimeout.description": "Amount of time (in ms) to wait for an interrupt before asking to restart the Jupyter kernel.", + "jupyter.configuration.jupyter.kernels.portRange.startPort.description": "Starting port number for Jupyter kernel port allocation. The extension will search for available ports starting from this number when launching local kernels and Jupyter servers. Valid range: 1024-65535.", "jupyter.configuration.jupyter.sendSelectionToInteractiveWindow.description": "When pressing shift+enter, send selected code in a Python file to the Jupyter interactive window as opposed to the Python terminal.", "jupyter.configuration.jupyter.normalizeSelectionForInteractiveWindow.description": "Selected text will be normalized before it is executed in the Interactive Window.", "jupyter.configuration.jupyter.splitRunFileIntoCells.description": "A file run in the Interactive Window will be run in individual cells if it has them.", diff --git a/src/kernels/jupyter/launcher/jupyterServerStarter.node.ts b/src/kernels/jupyter/launcher/jupyterServerStarter.node.ts index 7d8dd8ab139..8aa438a3f95 100644 --- a/src/kernels/jupyter/launcher/jupyterServerStarter.node.ts +++ b/src/kernels/jupyter/launcher/jupyterServerStarter.node.ts @@ -11,7 +11,7 @@ import { JUPYTER_OUTPUT_CHANNEL } from '../../../platform/common/constants'; import { dispose } from '../../../platform/common/utils/lifecycle'; import { logger } from '../../../platform/logging'; import { IFileSystem, TemporaryDirectory } from '../../../platform/common/platform/types'; -import { IDisposable, IOutputChannel, Resource } from '../../../platform/common/types'; +import { IConfigurationService, IDisposable, IOutputChannel, Resource } from '../../../platform/common/types'; import { DataScience } from '../../../platform/common/utils/localize'; import { JupyterConnectError } from '../../../platform/errors/jupyterConnectError'; import { JupyterInstallError } from '../../../platform/errors/jupyterInstallError'; @@ -46,6 +46,19 @@ export class JupyterServerStarter implements IJupyterServerStarter { @named(JUPYTER_OUTPUT_CHANNEL) private readonly jupyterOutputChannel: IOutputChannel ) {} + + private async getPortArguments(resource: Resource): Promise { + const configService = this.serviceContainer.get(IConfigurationService); + const settings = configService.getSettings(resource); + const startPort = settings.kernelPortRangeStartPort; + + // Only add port argument if it's different from the default (9000) + // This allows Jupyter to use its own port selection when using default + if (startPort && startPort !== 9000) { + return [`--port=${startPort}`]; + } + return []; + } public dispose() { while (this.disposables.length > 0) { const disposable = this.disposables.shift(); @@ -77,6 +90,7 @@ export class JupyterServerStarter implements IJupyterServerStarter { tempDirPromise.then((dir) => this.disposables.push(dir)).catch(noop); // Before starting the notebook process, make sure we generate a kernel spec const args = await this.generateArguments( + resource, useDefaultConfig, customCommandLine, tempDirPromise, @@ -176,6 +190,7 @@ export class JupyterServerStarter implements IJupyterServerStarter { } private async generateDefaultArguments( + resource: Resource, useDefaultConfig: boolean, tempDirPromise: Promise, workingDirectory: string @@ -190,7 +205,11 @@ export class JupyterServerStarter implements IJupyterServerStarter { // Modify the data rate limit if starting locally. The default prevents large dataframes from being returned. promisedArgs.push(Promise.resolve('--NotebookApp.iopub_data_rate_limit=10000000000.0')); - const [args, dockerArgs] = await Promise.all([Promise.all(promisedArgs), this.getDockerArguments()]); + const [args, dockerArgs, portArgs] = await Promise.all([ + Promise.all(promisedArgs), + this.getDockerArguments(), + this.getPortArguments(resource) + ]); // Check for the debug environment variable being set. Setting this // causes Jupyter to output a lot more information about what it's doing @@ -198,7 +217,7 @@ export class JupyterServerStarter implements IJupyterServerStarter { const debugArgs = process.env && process.env.VSCODE_JUPYTER_DEBUG_JUPYTER ? ['--debug'] : []; // Use this temp file and config file to generate a list of args for our command - return [...args, ...dockerArgs, ...debugArgs]; + return [...args, ...dockerArgs, ...portArgs, ...debugArgs]; } private async generateCustomArguments(customCommandLine: string[]): Promise { @@ -217,13 +236,14 @@ export class JupyterServerStarter implements IJupyterServerStarter { } private async generateArguments( + resource: Resource, useDefaultConfig: boolean, customCommandLine: string[], tempDirPromise: Promise, workingDirectory: string ): Promise { if (!customCommandLine || customCommandLine.length === 0) { - return this.generateDefaultArguments(useDefaultConfig, tempDirPromise, workingDirectory); + return this.generateDefaultArguments(resource, useDefaultConfig, tempDirPromise, workingDirectory); } return this.generateCustomArguments(customCommandLine); } diff --git a/src/kernels/raw/launcher/kernelLauncher.node.ts b/src/kernels/raw/launcher/kernelLauncher.node.ts index 07790c84410..2d9c61355e9 100644 --- a/src/kernels/raw/launcher/kernelLauncher.node.ts +++ b/src/kernels/raw/launcher/kernelLauncher.node.ts @@ -48,8 +48,18 @@ const PortFormatString = `kernelLauncherPortStart_{0}.tmp`; // If the selected interpreter doesn't have a kernel, it will find a kernel on disk and use that. @injectable() export class KernelLauncher implements IKernelLauncher { - private static startPortPromise = KernelLauncher.computeStartPort(); + private static startPortPromise: Promise | undefined; + private static cachedStartPort: number | undefined; private portChain: Promise | undefined; + + /** + * Reset the cached start port (for testing purposes) + * @internal + */ + public static resetStartPort(): void { + KernelLauncher.startPortPromise = undefined; + KernelLauncher.cachedStartPort = undefined; + } constructor( @inject(IProcessServiceFactory) private processExecutionFactory: IProcessServiceFactory, @inject(IFileSystemNode) private readonly fs: IFileSystemNode, @@ -64,10 +74,11 @@ export class KernelLauncher implements IKernelLauncher { @inject(IPlatformService) private readonly platformService: IPlatformService ) {} - private static async computeStartPort(): Promise { + private static async computeStartPort(configuredStartPort?: number): Promise { + const defaultStartPort = configuredStartPort || 9_000; if (isTestExecution()) { // Since multiple instances of a test may be running, write our best guess to a shared file - let portStart = 9_000; + let portStart = defaultStartPort; let result = 0; while (result === 0 && portStart < 65_000) { try { @@ -86,7 +97,7 @@ export class KernelLauncher implements IKernelLauncher { return result; } else { - return 9_000; + return defaultStartPort; } } @@ -200,7 +211,16 @@ export class KernelLauncher implements IKernelLauncher { } private async getConnectionPorts(): Promise { + // Get the configured start port from settings + const settings = this.configService.getSettings(undefined); + const configuredStartPort = settings.kernelPortRangeStartPort; + // Have to wait for static port lookup (it handles case where two VS code instances are running) + // Re-initialize if the configured port has changed + if (!KernelLauncher.startPortPromise || KernelLauncher.cachedStartPort !== configuredStartPort) { + KernelLauncher.cachedStartPort = configuredStartPort; + KernelLauncher.startPortPromise = KernelLauncher.computeStartPort(configuredStartPort); + } const startPort = await KernelLauncher.startPortPromise; // Then get the next set starting at that point diff --git a/src/kernels/raw/launcher/kernelLauncher.unit.test.ts b/src/kernels/raw/launcher/kernelLauncher.unit.test.ts index 2284e1ea8c8..152302c6fb8 100644 --- a/src/kernels/raw/launcher/kernelLauncher.unit.test.ts +++ b/src/kernels/raw/launcher/kernelLauncher.unit.test.ts @@ -59,7 +59,8 @@ suite('kernel Launcher', () => { when(pythonExecutionFactory.createActivatedEnvironment(anything())).thenResolve(instance(pythonExecService)); when(pythonExecService.exec(anything(), anything())).thenResolve({ stdout: '' }); when(configService.getSettings(anything())).thenReturn({ - jupyter: { logKernelOutputSeparately: false } + jupyter: { logKernelOutputSeparately: false }, + kernelPortRangeStartPort: 9000 } as any); kernelLauncher = new KernelLauncher( instance(processExecutionFactory), @@ -134,4 +135,67 @@ suite('kernel Launcher', () => { assert.isTrue(portForwardingIgnored, `Kernel Port ${port} should not be forwarded`); } }); + test('Verify custom start port is used from configuration', async () => { + // Reset the static port cache before this test + KernelLauncher.resetStartPort(); + + // Create a new launcher with custom port configuration + const customStartPort = 10000; + when(configService.getSettings(anything())).thenReturn({ + jupyter: { logKernelOutputSeparately: false }, + kernelPortRangeStartPort: customStartPort + } as any); + + const customKernelLauncher = new KernelLauncher( + instance(processExecutionFactory), + instance(fs), + instance(extensionChecker), + instance(kernelEnvVarsService), + disposables, + instance(pythonExecutionFactory), + instance(configService), + instance(jupyterPaths), + instance(pythonKernelInterruptDaemon), + instance(platform) + ); + + const kernelSpec = PythonKernelConnectionMetadata.create({ + id: '1', + interpreter: { + id: '2', + uri: Uri.file('python') + }, + kernelSpec: { + argv: ['python', '-m', 'ipykernel_launcher', '-f', '{connection_file}'], + display_name: 'Python 3', + executable: 'python', + name: 'python3' + } + }); + const cancellation = new CancellationTokenSource(); + const launchStub = sinon.stub(KernelProcess.prototype, 'launch'); + const exitedStub = sinon.stub(KernelProcess.prototype, 'exited'); + disposables.push(new Disposable(() => launchStub.restore())); + disposables.push(new Disposable(() => exitedStub.restore())); + launchStub.resolves(undefined); + const exited = new EventEmitter<{ + exitCode?: number | undefined; + reason?: string | undefined; + }>(); + exitedStub.get(() => exited.event); + + const oldPorts = new Set(UsedPorts); + await customKernelLauncher.launch(kernelSpec, 10_000, undefined, __dirname, cancellation.token); + + // Verify that ports allocated are >= custom start port + let foundPortInRange = false; + for (const port of UsedPorts) { + if (!oldPorts.has(port)) { + // This is a newly allocated port + assert.isAtLeast(port, customStartPort, `Port ${port} should be >= ${customStartPort}`); + foundPortInRange = true; + } + } + assert.isTrue(foundPortInRange, 'At least one port should have been allocated in the custom range'); + }); }); diff --git a/src/platform/common/configSettings.ts b/src/platform/common/configSettings.ts index 15385ad1757..02bee467d12 100644 --- a/src/platform/common/configSettings.ts +++ b/src/platform/common/configSettings.ts @@ -40,6 +40,7 @@ export class JupyterSettings implements IWatchableJupyterSettings { public experiments!: IExperiments; public allowUnauthorizedRemoteConnection: boolean = false; public jupyterInterruptTimeout: number = 10_000; + public kernelPortRangeStartPort: number = 9_000; public jupyterLaunchTimeout: number = 60_000; public jupyterLaunchRetries: number = 3; public notebookFileRoot: string = ''; diff --git a/src/platform/common/types.ts b/src/platform/common/types.ts index 8d634a34c2a..6c8816aae10 100644 --- a/src/platform/common/types.ts +++ b/src/platform/common/types.ts @@ -46,6 +46,7 @@ export interface IJupyterSettings { readonly experiments: IExperiments; readonly allowUnauthorizedRemoteConnection: boolean; readonly jupyterInterruptTimeout: number; + readonly kernelPortRangeStartPort: number; readonly jupyterLaunchTimeout: number; readonly jupyterLaunchRetries: number; readonly notebookFileRoot: string;