From 967c609c5a25ca64d729d7c9bc8e88ba2b3266f6 Mon Sep 17 00:00:00 2001 From: Samuel Olivier Date: Wed, 2 Sep 2026 15:25:25 +0200 Subject: [PATCH 1/3] fix: make locked-code message handler resilient to disposed contributions MessageController can be missing or already disposed. Throwing from showMessage aborted pushEditOperations and blocked typing. Co-authored-by: Cursor --- src/tools.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/tools.ts b/src/tools.ts index 7b2c985..729424d 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -64,9 +64,15 @@ type ErrorHandler = ( function generateErrorMessageErrorHandler(errorMessage: string): ErrorHandler { return (editor, operation) => { - const messageContribution = editor.getContribution('editor.contrib.messageController') - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ;(messageContribution as any).showMessage(errorMessage, operation.range.getStartPosition()) + try { + const messageContribution = editor.getContribution('editor.contrib.messageController') as { + showMessage(message: string, position: monaco.IPosition): void + } | null + messageContribution?.showMessage(errorMessage, operation.range.getStartPosition()) + } catch { + // Contributions can be missing or already disposed (e.g. InstantiationService has been + // disposed). Never throw from onError: that aborts pushEditOperations and blocks typing. + } } } From 55fcab213fd18e35b18b8e94050f72a022f91643 Mon Sep 17 00:00:00 2001 From: Samuel Olivier Date: Wed, 2 Sep 2026 15:25:51 +0200 Subject: [PATCH 2/3] fix: isolate lock onError failures from edit application A throwing custom onError aborted pushEditOperations. Catch it so forbidden edits are still rejected without blocking the editor. Co-authored-by: Cursor --- src/tools.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/tools.ts b/src/tools.ts index 729424d..21bb948 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -76,6 +76,21 @@ function generateErrorMessageErrorHandler(errorMessage: string): ErrorHandler { } } +function notifyLockedCodeError( + onError: ErrorHandler | undefined, + editor: monaco.editor.ICodeEditor, + operation: ValidAnnotatedEditOperation +) { + if (onError == null) { + return + } + try { + onError(editor, operation) + } catch (e) { + console.error(e) + } +} + export interface LockCodeOptions { /** * Error message displayed in a tooltip when an edit failed @@ -180,7 +195,7 @@ export function lockCodeRanges( (operation) => !canEditRange(operation.range) ) if (firstForbiddenOperation != null) { - onError?.(editor, firstForbiddenOperation) + notifyLockedCodeError(onError, editor, firstForbiddenOperation) return [] } else { return editorOperations @@ -188,7 +203,7 @@ export function lockCodeRanges( } else { return editorOperations.filter((operation) => { if (!canEditRange(operation.range)) { - onError?.(editor, operation) + notifyLockedCodeError(onError, editor, operation) return false } return true From a838df777df841e703c2394ce162553da661ce12 Mon Sep 17 00:00:00 2001 From: Samuel Olivier Date: Wed, 2 Sep 2026 15:25:51 +0200 Subject: [PATCH 3/3] test: cover lock error handler failures during executeEdits Co-authored-by: Cursor --- .../editorOperationUtils/lockedCode.test.ts | 67 ++++++++++++++++++- 1 file changed, 66 insertions(+), 1 deletion(-) diff --git a/src/tests/editorOperationUtils/lockedCode.test.ts b/src/tests/editorOperationUtils/lockedCode.test.ts index f260812..ed46b1d 100644 --- a/src/tests/editorOperationUtils/lockedCode.test.ts +++ b/src/tests/editorOperationUtils/lockedCode.test.ts @@ -16,7 +16,7 @@ import { createTestOperation, createTestRange } from '../utils' -import { lockCodeRanges } from '../../tools' +import { lockCodeRanges, lockCodeWithDecoration } from '../../tools' import { initialize } from '../../services' beforeAll(async () => { @@ -427,6 +427,71 @@ function findLargest(numbers: number[]): number { }) }) + test('Throwing onError still rejects the edit without failing executeEdits', () => { + const model = createDefaultTestModel() + disposableStore.add(model) + const editor = monaco.editor.create(document.createElement('div'), { + model + }) + disposableStore.add(editor) + disposableStore.add( + lockCodeRanges(editor, { + getLockedRanges() { + return createDefaultTestLockedCodeRanges(model) + }, + onError() { + throw new Error('InstantiationService has been disposed') + } + }) + ) + + const operationRange = createTestRange(model, 4, 4) + const operation = createTestOperation(operationRange, '// tata') + + const onDidChangeContent = jest.fn() + disposableStore.add(model.onDidChangeContent(onDidChangeContent)) + + expect(() => editor.executeEdits(null, [operation])).not.toThrow() + expect(onDidChangeContent).not.toHaveBeenCalled() + }) + + test('Missing message contribution still rejects the locked edit without failing executeEdits', () => { + const model = createDefaultTestModel() + disposableStore.add(model) + const editor = monaco.editor.create(document.createElement('div'), { + model + }) + disposableStore.add(editor) + const lockedRange = createDefaultTestLockedCodeRanges(model)[0]! + const decorations = editor.createDecorationsCollection([ + { + range: lockedRange, + options: { isWholeLine: true } + } + ]) + disposableStore.add( + lockCodeWithDecoration(editor, { + errorMessage: 'This section is read-only and cannot be edited', + decorationFilter: (decoration) => decorations.has(decoration) + }) + ) + const originalGetContribution = editor.getContribution.bind(editor) + jest.spyOn(editor, 'getContribution').mockImplementation((id: string) => { + if (id === 'editor.contrib.messageController') { + throw new Error('InstantiationService has been disposed') + } + return originalGetContribution(id) + }) + + const operation = createTestOperation(lockedRange, '// tata') + + const onDidChangeContent = jest.fn() + disposableStore.add(model.onDidChangeContent(onDidChangeContent)) + + expect(() => editor.executeEdits(null, [operation])).not.toThrow() + expect(onDidChangeContent).not.toHaveBeenCalled() + }) + test('Handle all systems line break character', () => { const model = createDefaultTestModel() disposableStore.add(model)