From 1f1cafff449b625972ae50bc7de7eeff73abb171 Mon Sep 17 00:00:00 2001 From: Andrew Dupont Date: Mon, 16 Feb 2026 11:13:14 -0800 Subject: [PATCH 1/2] Add test that reproduces the crash --- test/js/text-buffer.test.js | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/test/js/text-buffer.test.js b/test/js/text-buffer.test.js index c0f8ed8c..e49db86f 100644 --- a/test/js/text-buffer.test.js +++ b/test/js/text-buffer.test.js @@ -13,6 +13,10 @@ const MAX_INT32 = 4294967296 const isWindows = process.platform === 'win32' +async function wait (ms) { + return new Promise(r => setTimeout(r, ms)); +} + const encodings = [ 'big5hkscs', 'cp850', @@ -1261,6 +1265,25 @@ describe('TextBuffer', () => { return Promise.all(promises) }) + it('doesn\'t crash when a job is cancelled', async () => { + function randomChar () { + const chars = "abcdefghijklmnopqrstuvwxyz "; + return chars[Math.floor(Math.random() * chars.length)]; + } + let buffer = new TextBuffer(`lorem ipsum dolor sit amet, consecuetur adipiscing elit`) + // This test triggers a known segfault scenario in which a + // `FindWordsWithSubsequenceInRangeWorker` is created and then needs to + // be cancelled because of a subsequent call to `set_text_in_range`. Just + // like the test above, this one will either pass without making any + // assertions… or crash. + for (let k = 0; k < 100; k++) { + let ch = randomChar() + buffer.findWordsWithSubsequence('lor', '(){} :;,$@%', 20) + buffer.setTextInRange({ start: { row: 0, column: 8 }, end: { row: 0, column: 9 } }, ch) + await wait(Math.round(Math.random() * 20)) + } + }) + it('resolves with all words matching the given query', () => { const buffer = new TextBuffer('banana bandana ban_ana bandaid band bNa\nbanana') return buffer.findWordsWithSubsequence('bna', '_', 4).then((result) => { From b89da521834e50cccba3532ab4e3477d106ade4b Mon Sep 17 00:00:00 2001 From: Andrew Dupont Date: Mon, 16 Feb 2026 11:19:30 -0800 Subject: [PATCH 2/2] Move removal of job into `OnWorkComplete` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The theory of the crash, produced during a rubber-ducking session with Claude: * `find_words_with_subsequence_in_range` is called * a job is scheduled and placed in `outstanding_workers` * a call to `set_text_in_range` cancels a pending worker via `cancel_queued_workers` and `worker->Cancel` * the worker is never removed from `outstanding_workers` because that happens in `Execute` — and we cancelled before the job got that far * later, `set_text_in_range` triggers another call to `cancel_queued_workers` * the already-cancelled job from before is still present in the set * we try to call `Cancel` on it again, but the memory has been freed The fix is to take the code that removes a job from `outstanding_workers` and move it from `Execute` to `OnWorkComplete`. The latter is guaranteed to be called, no matter how a job finished. Because this is a theory produced by a man and his hallucinating robot, it's important to back it up with proof. The test added in the previous commit should now pass instead of crashing. So this fix works, even if there are slight flaws in the reasoning above. --- src/bindings/text-buffer-wrapper.cc | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/bindings/text-buffer-wrapper.cc b/src/bindings/text-buffer-wrapper.cc index 820cb991..5e7719e2 100644 --- a/src/bindings/text-buffer-wrapper.cc +++ b/src/bindings/text-buffer-wrapper.cc @@ -623,6 +623,11 @@ void TextBufferWrapper::find_words_with_subsequence_in_range(const CallbackInfo } void OnWorkComplete(Napi::Env env, napi_status status) override { + { + std::lock_guard guard(text_buffer_wrapper->outstanding_workers_mutex); + text_buffer_wrapper->outstanding_workers.erase(this); + } + if (status == napi_cancelled) { Callback().Call({env.Null()}); } @@ -631,11 +636,6 @@ void TextBufferWrapper::find_words_with_subsequence_in_range(const CallbackInfo } void Execute() override { - { - std::lock_guard guard(text_buffer_wrapper->outstanding_workers_mutex); - text_buffer_wrapper->outstanding_workers.erase(this); - } - if (!snapshot) { return; }