Skip to content

prevent native REPL from caching state between sessions #24857

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Apr 2, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/actions/smoke-tests/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,13 @@ runs:
using: 'composite'
steps:
- name: Install Node
uses: actions/setup-node@v2
uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node_version }}
cache: 'npm'

- name: Install Python
uses: actions/setup-python@v2
uses: actions/setup-python@v5
with:
python-version: '3.x'
cache: 'pip'
Expand Down
4 changes: 4 additions & 0 deletions src/client/common/vscodeApis/workspaceApis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ export function onDidChangeConfiguration(handler: (e: vscode.ConfigurationChange
return vscode.workspace.onDidChangeConfiguration(handler);
}

export function onDidCloseNotebookDocument(handler: (e: vscode.NotebookDocument) => void): vscode.Disposable {
return vscode.workspace.onDidCloseNotebookDocument(handler);
}

export function createFileSystemWatcher(
globPattern: vscode.GlobPattern,
ignoreCreateEvents?: boolean,
Expand Down
15 changes: 12 additions & 3 deletions src/client/repl/nativeRepl.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

// Native Repl class that holds instance of pythonServer and replController

import {
Expand All @@ -7,13 +10,12 @@ import {
QuickPickItem,
TextEditor,
Uri,
workspace,
WorkspaceFolder,
} from 'vscode';
import { Disposable } from 'vscode-jsonrpc';
import { PVSC_EXTENSION_ID } from '../common/constants';
import { showQuickPick } from '../common/vscodeApis/windowApis';
import { getWorkspaceFolders } from '../common/vscodeApis/workspaceApis';
import { getWorkspaceFolders, onDidCloseNotebookDocument } from '../common/vscodeApis/workspaceApis';
import { PythonEnvironment } from '../pythonEnvironments/info';
import { createPythonServer, PythonServer } from './pythonServer';
import { executeNotebookCell, openInteractiveREPL, selectNotebookKernel } from './replCommandHandler';
Expand Down Expand Up @@ -69,11 +71,18 @@ export class NativeRepl implements Disposable {
*/
private watchNotebookClosed(): void {
this.disposables.push(
workspace.onDidCloseNotebookDocument(async (nb) => {
onDidCloseNotebookDocument(async (nb) => {
if (this.notebookDocument && nb.uri.toString() === this.notebookDocument.uri.toString()) {
this.notebookDocument = undefined;
this.newReplSession = true;
await updateWorkspaceStateValue<string | undefined>(NATIVE_REPL_URI_MEMENTO, undefined);
this.pythonServer.dispose();
this.pythonServer = createPythonServer([this.interpreter.path as string], this.cwd);
this.disposables.push(this.pythonServer);
if (this.replController) {
this.replController.dispose();
}
nativeRepl = undefined;
}
}),
);
Expand Down
1 change: 1 addition & 0 deletions src/client/repl/pythonServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ class PythonServerImpl implements PythonServer, Disposable {
this.connection.sendNotification('exit');
this.disposables.forEach((d) => d.dispose());
this.connection.dispose();
serverInstance = undefined;
}
}

Expand Down
115 changes: 102 additions & 13 deletions src/test/repl/nativeRepl.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

/* eslint-disable no-unused-expressions */
/* eslint-disable @typescript-eslint/no-explicit-any */
import * as TypeMoq from 'typemoq';
import * as sinon from 'sinon';
import { Disposable } from 'vscode';
import { Disposable, EventEmitter, NotebookDocument, Uri } from 'vscode';
import { expect } from 'chai';

import { IInterpreterService } from '../../client/interpreter/contracts';
import { PythonEnvironment } from '../../client/pythonEnvironments/info';
import { getNativeRepl, NativeRepl } from '../../client/repl/nativeRepl';
import * as NativeReplModule from '../../client/repl/nativeRepl';
import * as persistentState from '../../client/common/persistentState';
import * as PythonServer from '../../client/repl/pythonServer';
import * as vscodeWorkspaceApis from '../../client/common/vscodeApis/workspaceApis';
import * as replController from '../../client/repl/replController';
import { executeCommand } from '../../client/common/vscodeApis/commandApis';

suite('REPL - Native REPL', () => {
let interpreterService: TypeMoq.IMock<IInterpreterService>;
Expand All @@ -19,38 +26,51 @@ suite('REPL - Native REPL', () => {
let setReplControllerSpy: sinon.SinonSpy;
let getWorkspaceStateValueStub: sinon.SinonStub;
let updateWorkspaceStateValueStub: sinon.SinonStub;
let createReplControllerStub: sinon.SinonStub;
let mockNotebookController: any;

setup(() => {
(NativeReplModule as any).nativeRepl = undefined;

mockNotebookController = {
id: 'mockController',
dispose: sinon.stub(),
updateNotebookAffinity: sinon.stub(),
createNotebookCellExecution: sinon.stub(),
variableProvider: null,
};

interpreterService = TypeMoq.Mock.ofType<IInterpreterService>();
interpreterService
.setup((i) => i.getActiveInterpreter(TypeMoq.It.isAny()))
.returns(() => Promise.resolve(({ path: 'ps' } as unknown) as PythonEnvironment));
disposable = TypeMoq.Mock.ofType<Disposable>();
disposableArray = [disposable.object];

setReplDirectoryStub = sinon.stub(NativeRepl.prototype as any, 'setReplDirectory').resolves(); // Stubbing private method
// Use a spy instead of a stub for setReplController
setReplControllerSpy = sinon.spy(NativeRepl.prototype, 'setReplController');
createReplControllerStub = sinon.stub(replController, 'createReplController').returns(mockNotebookController);
setReplDirectoryStub = sinon.stub(NativeReplModule.NativeRepl.prototype as any, 'setReplDirectory').resolves();
setReplControllerSpy = sinon.spy(NativeReplModule.NativeRepl.prototype, 'setReplController');
updateWorkspaceStateValueStub = sinon.stub(persistentState, 'updateWorkspaceStateValue').resolves();
});

teardown(() => {
teardown(async () => {
disposableArray.forEach((d) => {
if (d) {
d.dispose();
}
});
disposableArray = [];
sinon.restore();
executeCommand('workbench.action.closeActiveEditor');
});

test('getNativeRepl should call create constructor', async () => {
const createMethodStub = sinon.stub(NativeRepl, 'create');
const createMethodStub = sinon.stub(NativeReplModule.NativeRepl, 'create');
interpreterService
.setup((i) => i.getActiveInterpreter(TypeMoq.It.isAny()))
.returns(() => Promise.resolve(({ path: 'ps' } as unknown) as PythonEnvironment));
const interpreter = await interpreterService.object.getActiveInterpreter();
await getNativeRepl(interpreter as PythonEnvironment, disposableArray);
await NativeReplModule.getNativeRepl(interpreter as PythonEnvironment, disposableArray);

expect(createMethodStub.calledOnce).to.be.true;
});
Expand All @@ -61,7 +81,7 @@ suite('REPL - Native REPL', () => {
.setup((i) => i.getActiveInterpreter(TypeMoq.It.isAny()))
.returns(() => Promise.resolve(({ path: 'ps' } as unknown) as PythonEnvironment));
const interpreter = await interpreterService.object.getActiveInterpreter();
const nativeRepl = await getNativeRepl(interpreter as PythonEnvironment, disposableArray);
const nativeRepl = await NativeReplModule.getNativeRepl(interpreter as PythonEnvironment, disposableArray);

nativeRepl.sendToNativeRepl(undefined, false);

Expand All @@ -74,7 +94,7 @@ suite('REPL - Native REPL', () => {
.setup((i) => i.getActiveInterpreter(TypeMoq.It.isAny()))
.returns(() => Promise.resolve(({ path: 'ps' } as unknown) as PythonEnvironment));
const interpreter = await interpreterService.object.getActiveInterpreter();
const nativeRepl = await getNativeRepl(interpreter as PythonEnvironment, disposableArray);
const nativeRepl = await NativeReplModule.getNativeRepl(interpreter as PythonEnvironment, disposableArray);

nativeRepl.sendToNativeRepl(undefined, false);

Expand All @@ -87,12 +107,81 @@ suite('REPL - Native REPL', () => {
.setup((i) => i.getActiveInterpreter(TypeMoq.It.isAny()))
.returns(() => Promise.resolve(({ path: 'ps' } as unknown) as PythonEnvironment));

await NativeRepl.create(interpreter as PythonEnvironment);
await NativeReplModule.NativeRepl.create(interpreter as PythonEnvironment);

expect(setReplDirectoryStub.calledOnce).to.be.true;
expect(setReplControllerSpy.calledOnce).to.be.true;
expect(createReplControllerStub.calledOnce).to.be.true;
});

test('watchNotebookClosed should clean up resources when notebook is closed', async () => {
const notebookCloseEmitter = new EventEmitter<NotebookDocument>();
sinon.stub(vscodeWorkspaceApis, 'onDidCloseNotebookDocument').callsFake((handler) => {
const disposable = notebookCloseEmitter.event(handler);
return disposable;
});

const mockPythonServer = {
onCodeExecuted: new EventEmitter<void>().event,
execute: sinon.stub().resolves({ status: true, output: 'test output' }),
executeSilently: sinon.stub().resolves({ status: true, output: 'test output' }),
interrupt: sinon.stub(),
input: sinon.stub(),
checkValidCommand: sinon.stub().resolves(true),
dispose: sinon.stub(),
};

// Track the number of times createPythonServer was called
let createPythonServerCallCount = 0;
sinon.stub(PythonServer, 'createPythonServer').callsFake(() => {
// eslint-disable-next-line no-plusplus
createPythonServerCallCount++;
return mockPythonServer;
});

const interpreter = await interpreterService.object.getActiveInterpreter();

setReplDirectoryStub.restore();
setReplControllerSpy.restore();
// Create NativeRepl directly to have more control over its state, go around private constructor.
const nativeRepl = new (NativeReplModule.NativeRepl as any)();
nativeRepl.interpreter = interpreter as PythonEnvironment;
nativeRepl.cwd = '/helloJustMockedCwd/cwd';
nativeRepl.pythonServer = mockPythonServer;
nativeRepl.replController = mockNotebookController;
nativeRepl.disposables = [];

// Make the singleton point to our instance for testing
// Otherwise, it gets mixed with Native Repl from .create from test above.
(NativeReplModule as any).nativeRepl = nativeRepl;

// Reset call count after initial setup
createPythonServerCallCount = 0;

// Set notebookDocument to a mock document
const mockReplUri = Uri.parse('untitled:Untitled-999.ipynb?jupyter-notebook');
const mockNotebookDocument = ({
uri: mockReplUri,
toString: () => mockReplUri.toString(),
} as unknown) as NotebookDocument;

nativeRepl.notebookDocument = mockNotebookDocument;

// Create a mock notebook document for closing event with same URI
const closingNotebookDocument = ({
uri: mockReplUri,
toString: () => mockReplUri.toString(),
} as unknown) as NotebookDocument;

notebookCloseEmitter.fire(closingNotebookDocument);
await new Promise((resolve) => setTimeout(resolve, 50));

expect(
updateWorkspaceStateValueStub.calledWith(NativeReplModule.NATIVE_REPL_URI_MEMENTO, undefined),
'updateWorkspaceStateValue should be called with NATIVE_REPL_URI_MEMENTO and undefined',
).to.be.true;
expect(mockPythonServer.dispose.calledOnce, 'pythonServer.dispose() should be called once').to.be.true;
expect(createPythonServerCallCount, 'createPythonServer should be called to create a new server').to.equal(1);
expect(nativeRepl.notebookDocument, 'notebookDocument should be undefined after closing').to.be.undefined;
expect(nativeRepl.newReplSession, 'newReplSession should be set to true after closing').to.be.true;
expect(mockNotebookController.dispose.calledOnce, 'replController.dispose() should be called once').to.be.true;
});
});
Loading