core: fix Node runtime integration (#1160)

Preserve worker messages until module evaluation installs a handler, and
keep handler cleanup from reviving stale registrations. This prevents startup
message loss and top-level-await deadlocks.

Respect renderer input ownership during teardown and expose CommonJS
entry points only when their emitted graphs are synchronous.

Reject pending work when workers fail or clients shut down so callers do
not hang. 

Serialize teardown
This commit is contained in:
Simon Klee
2026-06-09 12:43:17 +02:00
committed by GitHub
parent dfb871ebed
commit 4b3e0025d5
14 changed files with 758 additions and 108 deletions
+5
View File
@@ -126,6 +126,11 @@ jobs:
working-directory: packages/core
run: bun run test:js:node
- name: Test packed distribution
if: runner.os == 'Linux'
working-directory: packages/core
run: bun run test:dist --skip-build
# Gate job for branch protection
build-complete:
name: Core - Build and Test
+7 -2
View File
@@ -457,13 +457,18 @@ if (buildLib) {
node: "./runtime-plugin-support-configure.node.js",
default: "./runtime-plugin-support-configure.node.js",
},
// Conditional exports select the first matching key in declaration order. Bun
// matches `bun` for both import and require, while Node ESM falls through to
// `import`. There is deliberately no `require` or `default`: this module uses
// top-level await, so directing Node CommonJS to it would fail during evaluation.
"./tree-sitter/update-assets": {
import: "./lib/tree-sitter/update-assets.js",
require: "./lib/tree-sitter/update-assets.js",
types: "./lib/tree-sitter/update-assets.d.ts",
bun: "./lib/tree-sitter/update-assets.js",
import: "./lib/tree-sitter/update-assets.js",
},
"./parser.worker": {
import: "./parser.worker.js",
require: "./parser.worker.js",
types: "./lib/tree-sitter/parser.worker.d.ts",
},
}
+20
View File
@@ -172,6 +172,25 @@ await expectBunOnlyFailure(
)
console.log("Node dist smoke test passed")
`,
)
writeFileSync(
join(nodeDir, "require.cjs"),
`const assert = require("node:assert/strict")
for (const specifier of [${JSON.stringify(packageJson.name)}, ${JSON.stringify(`${packageJson.name}/testing`)}, ${JSON.stringify(`${packageJson.name}/tree-sitter/update-assets`)}]) {
assert.throws(
() => require(specifier),
(error) => error?.code === "ERR_PACKAGE_PATH_NOT_EXPORTED",
\`Expected \${specifier} to remain import-only in Node\`,
)
}
const workerPath = require.resolve(${JSON.stringify(`${packageJson.name}/parser.worker`)})
assert.match(workerPath, /parser\\.worker\\.js$/)
console.log("Node CommonJS export smoke test passed")
`,
)
}
@@ -228,6 +247,7 @@ function installAndTest(nodeDir: string, bunDir: string): void {
runCommand("npm", ["install", "--ignore-scripts", "--no-package-lock"], nodeDir, "Node dist test install failed")
runCommand(nodePath, ["-e", `import(${JSON.stringify(packageJson.name)})`], nodeDir, "Node import smoke check failed")
runCommand(nodePath, ["index.mjs"], nodeDir, "Node dist smoke tests failed")
runCommand(nodePath, ["require.cjs"], nodeDir, "Node CommonJS export smoke tests failed")
assertNodeStaticImportFailure(
nodeDir,
+1
View File
@@ -38,6 +38,7 @@ const nodePath = requireNode26()
const emittedAllowlist = [
".node-test/src/platform/ffi.test.js",
".node-test/src/platform/runtime.test.js",
".node-test/src/platform/worker.node-test.js",
".node-test/src/lib/bunfs.test.js",
".node-test/src/lib/border.test.js",
".node-test/src/lib/clipboard.test.js",
@@ -114,6 +114,48 @@ describe("TreeSitterClient", () => {
expect(client.isInitialized()).toBe(true)
})
test("should reject initialization when destroyed during default parser registration", async () => {
let resolveRegistrationStarted!: () => void
let resolveRegistration!: () => void
const registrationStarted = new Promise<void>((resolve) => {
resolveRegistrationStarted = resolve
})
const registrationGate = new Promise<void>((resolve) => {
resolveRegistration = resolve
})
const clientInternals = client as unknown as { registerDefaultParsers: () => Promise<void> }
const registerDefaultParsers = clientInternals.registerDefaultParsers.bind(client)
clientInternals.registerDefaultParsers = async () => {
resolveRegistrationStarted()
await registrationGate
await registerDefaultParsers()
}
const initializeOutcome = client.initialize().then(
() => ({ status: "fulfilled" as const }),
(error: unknown) => ({ status: "rejected" as const, error }),
)
try {
await registrationStarted
await client.destroy()
resolveRegistration()
const outcome = await initializeOutcome
expect(outcome.status).toBe("rejected")
if (outcome.status === "rejected") {
expect(outcome.error).toBeInstanceOf(Error)
expect((outcome.error as Error).message).toBe("Client destroyed during initialization")
}
expect(client.isInitialized()).toBe(false)
} finally {
resolveRegistration()
await initializeOutcome
await client.destroy()
}
})
test("should preload parsers for supported filetypes", async () => {
await client.initialize()
@@ -1189,6 +1231,131 @@ describe("TreeSitterClient Edge Cases", () => {
expect(client.isInitialized()).toBe(false)
})
test("should reject initialization while worker termination is pending", async () => {
const client = new TreeSitterClient({ dataPath })
await client.initialize()
const internals = client as unknown as {
worker?: { terminate: () => void | Promise<number> }
}
const worker = internals.worker
expect(worker).toBeDefined()
if (!worker) {
throw new Error("Expected initialized client to have a worker")
}
let resolveTermination!: () => void
const terminationGate = new Promise<void>((resolve) => {
resolveTermination = resolve
})
const originalTerminate = worker.terminate.bind(worker)
worker.terminate = async () => {
await terminationGate
const result = originalTerminate()
return result && typeof (result as PromiseLike<number>).then === "function" ? await result : 0
}
const destroyPromise = client.destroy()
try {
await expect(client.initialize()).rejects.toThrow("Cannot initialize while client is being destroyed")
expect(client.isInitialized()).toBe(false)
resolveTermination()
await destroyPromise
await client.initialize()
expect(client.isInitialized()).toBe(true)
expect(internals.worker).not.toBe(worker)
} finally {
resolveTermination()
await destroyPromise
await client.destroy()
}
})
test("should retain the worker when termination fails so destroy can be retried", async () => {
const client = new TreeSitterClient({ dataPath })
await client.initialize()
const internals = client as unknown as {
worker?: { terminate: () => void | Promise<number> }
}
const worker = internals.worker
expect(worker).toBeDefined()
if (!worker) {
throw new Error("Expected initialized client to have a worker")
}
const originalTerminate = worker.terminate.bind(worker)
worker.terminate = async () => {
throw new Error("synthetic termination failure")
}
await expect(client.destroy()).rejects.toThrow("synthetic termination failure")
expect(internals.worker).toBe(worker)
await expect(client.initialize()).rejects.toThrow("retry destroy()")
worker.terminate = originalTerminate
await client.destroy()
expect(internals.worker).toBeUndefined()
})
test("should reject pending requests when an initialized worker errors", async () => {
const client = new TreeSitterClient({ dataPath })
await client.initialize()
const internals = client as unknown as {
messageCallbacks: Map<string, unknown>
worker?: {
onerror: ((event: { message: string; error?: unknown }) => void) | null
postMessage: (message: { type?: string }) => void
}
}
const worker = internals.worker
expect(worker).toBeDefined()
if (!worker) {
throw new Error("Expected initialized client to have a worker")
}
const originalPostMessage = worker.postMessage.bind(worker)
const blockedTypes = new Set(["GET_PERFORMANCE", "PRELOAD_PARSER", "ONESHOT_HIGHLIGHT"])
worker.postMessage = (message) => {
if (!blockedTypes.has(message.type ?? "")) {
originalPostMessage(message)
}
}
const observe = <T>(promise: Promise<T>) =>
promise.then(
(value) => ({ status: "fulfilled" as const, value }),
(error: unknown) => ({ status: "rejected" as const, error }),
)
const outcomes = [
observe(client.getPerformance()),
observe(client.preloadParser("javascript")),
observe(client.highlightOnce("const value = 1", "javascript")),
]
try {
expect(internals.messageCallbacks.size).toBe(3)
expect(worker.onerror).not.toBeNull()
worker.onerror?.({ message: "synthetic post-init failure" })
expect(client.isInitialized()).toBe(false)
expect(internals.messageCallbacks.size).toBe(0)
for (const outcome of await Promise.all(outcomes)) {
expect(outcome.status).toBe("rejected")
if (outcome.status === "rejected") {
expect(outcome.error).toBeInstanceOf(Error)
expect((outcome.error as Error).message).toContain("synthetic post-init failure")
}
}
} finally {
await client.destroy()
await Promise.all(outcomes)
}
})
test("should handle worker errors gracefully", async () => {
const client = new TreeSitterClient({ dataPath })
+254 -101
View File
@@ -50,6 +50,11 @@ interface TreeSitterClientInternalOptions {
autoStartWorker?: boolean
}
interface PendingRequest {
resolve: (response: any) => void
reject: (error: Error) => void
}
let DEFAULT_PARSER_OVERRIDES: FiletypeParserOptions[] = []
export function addDefaultParsers(parsers: FiletypeParserOptions[]): void {
@@ -73,12 +78,16 @@ export class TreeSitterClient extends EventEmitter<TreeSitterClientEvents> {
private initializeResolvers:
| { resolve: () => void; reject: (error: Error) => void; timeoutId: ReturnType<typeof setTimeout> }
| undefined
private messageCallbacks: Map<string, (response: any) => void> = new Map()
private messageCallbacks = new Map<string, PendingRequest>()
private messageIdCounter: number = 0
private editQueues: Map<number, ProcessQueue<EditQueueItem>> = new Map()
private debouncer: DebounceController
private options: TreeSitterClientOptions
private destroyCallbacks = new Set<() => void>()
private lifecycleGeneration = 0
private rejectInitialization: ((error: Error) => void) | undefined
private destroyPromise: Promise<void> | undefined
private workerTerminationFailed = false
constructor(options: TreeSitterClientOptions, internalOptions: TreeSitterClientInternalOptions = {}) {
super()
@@ -132,20 +141,59 @@ export class TreeSitterClient extends EventEmitter<TreeSitterClientEvents> {
}
console.error("TreeSitter worker error:", error.message)
// If we're still initializing, reject the init promise
if (this.initializeResolvers) {
clearTimeout(this.initializeResolvers.timeoutId)
this.initializeResolvers.reject(new Error(`Worker error: ${error.message}`))
this.initializeResolvers = undefined
}
const workerError = new Error(`Worker error: ${error.message}`, { cause: error.error })
this.handleWorkerFailure(worker, workerError)
this.emitError(`Worker error: ${error.message}`)
}
}
private sendWorkerMessage(message: TreeSitterWorkerRequest): void {
this.worker?.postMessage(message)
if (!this.worker) {
throw new Error("TreeSitter worker is not available")
}
this.worker.postMessage(message)
}
private rejectPendingRequests(error: Error): void {
const requests = Array.from(this.messageCallbacks.values())
this.messageCallbacks.clear()
for (const request of requests) {
request.reject(error)
}
}
private rejectActiveInitialization(error: Error): void {
if (this.initializeResolvers) {
clearTimeout(this.initializeResolvers.timeoutId)
this.initializeResolvers.reject(error)
this.initializeResolvers = undefined
}
this.rejectInitialization?.(error)
this.rejectInitialization = undefined
}
private handleWorkerFailure(worker: TreeSitterWorkerHandle, error: Error): void {
if (this.worker !== worker) {
return
}
worker.onmessage = null
worker.onerror = null
this.worker = undefined
this.lifecycleGeneration++
this.initialized = false
this.initializePromise = undefined
this.rejectActiveInitialization(error)
this.rejectPendingRequests(error)
this.editQueues.clear()
this.buffers.clear()
this.debouncer.clear()
try {
void Promise.resolve(worker.terminate()).catch(() => {})
} catch {
// The worker has already failed; cleanup is best effort.
}
}
// Path resolution stays in the client for now; runtime-specific Worker construction lives in platform/worker.
@@ -177,13 +225,24 @@ export class TreeSitterClient extends EventEmitter<TreeSitterClientEvents> {
return
}
const onmessage = worker.onmessage
const onerror = worker.onerror
worker.onmessage = null
worker.onerror = null
this.worker = undefined
const termination = worker.terminate()
if (termination && typeof (termination as PromiseLike<number>).then === "function") {
await termination
try {
const termination = worker.terminate()
if (termination && typeof (termination as PromiseLike<number>).then === "function") {
await termination
}
} catch (error) {
if (!this.worker) {
worker.onmessage = onmessage
worker.onerror = onerror
this.worker = worker
}
throw error
}
}
@@ -199,6 +258,13 @@ export class TreeSitterClient extends EventEmitter<TreeSitterClientEvents> {
}
async initialize(): Promise<void> {
if (this.destroyPromise) {
throw new Error("Cannot initialize while client is being destroyed")
}
if (this.workerTerminationFailed) {
throw new Error("Cannot initialize after worker termination failed; retry destroy()")
}
if (this.initializePromise) {
return this.initializePromise
}
@@ -207,12 +273,39 @@ export class TreeSitterClient extends EventEmitter<TreeSitterClientEvents> {
this.startWorker()
}
this.initializePromise = this.initializeClient()
const worker = this.worker!
const generation = this.lifecycleGeneration
let rejectCancellation!: (error: Error) => void
const cancellation = new Promise<never>((_, reject) => {
rejectCancellation = reject
})
const initialization = Promise.race([this.initializeClient(generation, worker), cancellation])
this.rejectInitialization = rejectCancellation
this.initializePromise = initialization
void initialization.then(
() => {
if (this.initializePromise === initialization) {
this.rejectInitialization = undefined
}
},
() => {
if (this.initializePromise === initialization) {
this.rejectInitialization = undefined
}
},
)
return this.initializePromise
}
private async initializeClient(): Promise<void> {
private assertCurrentInitialization(generation: number, worker: TreeSitterWorkerHandle): void {
if (this.lifecycleGeneration !== generation || this.worker !== worker || this.destroyPromise) {
throw new Error("TreeSitter initialization was invalidated")
}
}
private async initializeClient(generation: number, worker: TreeSitterWorkerHandle): Promise<void> {
await new Promise<void>((resolve, reject) => {
const timeoutMs = this.options.initTimeout ?? 10000 // Default to 10 seconds
const timeoutId = setTimeout(() => {
@@ -229,19 +322,25 @@ export class TreeSitterClient extends EventEmitter<TreeSitterClientEvents> {
})
})
await this.registerDefaultParsers()
this.assertCurrentInitialization(generation, worker)
await this.registerDefaultParsers(generation, worker)
this.assertCurrentInitialization(generation, worker)
this.initialized = true
}
private async registerDefaultParsers(): Promise<void> {
private async registerDefaultParsers(
generation: number = this.lifecycleGeneration,
worker: TreeSitterWorkerHandle = this.worker!,
): Promise<void> {
const defaultParsers = await getParsers()
this.assertCurrentInitialization(generation, worker)
const overriddenFiletypes = new Set(DEFAULT_PARSER_OVERRIDES.map((parser) => parser.filetype))
for (const parser of [
...defaultParsers.filter((parser) => !overriddenFiletypes.has(parser.filetype)),
...DEFAULT_PARSER_OVERRIDES,
]) {
this.addFiletypeParser(parser)
worker.postMessage({ type: "ADD_FILETYPE_PARSER", filetypeParser: this.resolveFiletypeParser(parser) })
}
}
@@ -259,7 +358,11 @@ export class TreeSitterClient extends EventEmitter<TreeSitterClientEvents> {
}
public addFiletypeParser(filetypeParser: FiletypeParserOptions): void {
const resolvedParser: FiletypeParserOptions = {
this.sendWorkerMessage({ type: "ADD_FILETYPE_PARSER", filetypeParser: this.resolveFiletypeParser(filetypeParser) })
}
private resolveFiletypeParser(filetypeParser: FiletypeParserOptions): FiletypeParserOptions {
return {
...filetypeParser,
aliases: filetypeParser.aliases
? [...new Set(filetypeParser.aliases.filter((alias) => alias !== filetypeParser.filetype))]
@@ -270,14 +373,18 @@ export class TreeSitterClient extends EventEmitter<TreeSitterClientEvents> {
injections: filetypeParser.queries.injections?.map((path) => this.resolvePath(path)),
},
}
this.sendWorkerMessage({ type: "ADD_FILETYPE_PARSER", filetypeParser: resolvedParser })
}
public async getPerformance(): Promise<PerformanceStats> {
const messageId = `performance_${this.messageIdCounter++}`
return new Promise<PerformanceStats>((resolve) => {
this.messageCallbacks.set(messageId, resolve)
this.sendWorkerMessage({ type: "GET_PERFORMANCE", messageId })
return new Promise<PerformanceStats>((resolve, reject) => {
this.messageCallbacks.set(messageId, { resolve, reject })
try {
this.sendWorkerMessage({ type: "GET_PERFORMANCE", messageId })
} catch (error) {
this.messageCallbacks.delete(messageId)
reject(error instanceof Error ? error : new Error(String(error)))
}
})
}
@@ -294,14 +401,19 @@ export class TreeSitterClient extends EventEmitter<TreeSitterClientEvents> {
}
const messageId = `oneshot_${this.messageIdCounter++}`
return new Promise((resolve) => {
this.messageCallbacks.set(messageId, resolve)
this.sendWorkerMessage({
type: "ONESHOT_HIGHLIGHT",
content,
filetype,
messageId,
})
return new Promise((resolve, reject) => {
this.messageCallbacks.set(messageId, { resolve, reject })
try {
this.sendWorkerMessage({
type: "ONESHOT_HIGHLIGHT",
content,
filetype,
messageId,
})
} catch (error) {
this.messageCallbacks.delete(messageId)
reject(error instanceof Error ? error : new Error(String(error)))
}
})
}
@@ -346,7 +458,7 @@ export class TreeSitterClient extends EventEmitter<TreeSitterClientEvents> {
const callback = this.messageCallbacks.get(message.messageId)
if (callback) {
this.messageCallbacks.delete(message.messageId)
callback({ hasParser: message.hasParser, warning: message.warning, error: message.error })
callback.resolve({ hasParser: message.hasParser, warning: message.warning, error: message.error })
}
return
}
@@ -355,7 +467,7 @@ export class TreeSitterClient extends EventEmitter<TreeSitterClientEvents> {
const callback = this.messageCallbacks.get(message.messageId)
if (callback) {
this.messageCallbacks.delete(message.messageId)
callback({ hasParser: message.hasParser })
callback.resolve({ hasParser: message.hasParser })
}
return
}
@@ -364,7 +476,7 @@ export class TreeSitterClient extends EventEmitter<TreeSitterClientEvents> {
const callback = this.messageCallbacks.get(`dispose_${message.bufferId}`)
if (callback) {
this.messageCallbacks.delete(`dispose_${message.bufferId}`)
callback(true)
callback.resolve(true)
}
this.emit("buffer:disposed", message.bufferId)
@@ -375,7 +487,7 @@ export class TreeSitterClient extends EventEmitter<TreeSitterClientEvents> {
const callback = this.messageCallbacks.get(message.messageId)
if (callback) {
this.messageCallbacks.delete(message.messageId)
callback(message.performance)
callback.resolve(message.performance)
}
return
}
@@ -384,7 +496,7 @@ export class TreeSitterClient extends EventEmitter<TreeSitterClientEvents> {
const callback = this.messageCallbacks.get(message.messageId)
if (callback) {
this.messageCallbacks.delete(message.messageId)
callback({ highlights: message.highlights, warning: message.warning, error: message.error })
callback.resolve({ highlights: message.highlights, warning: message.warning, error: message.error })
}
return
}
@@ -393,7 +505,7 @@ export class TreeSitterClient extends EventEmitter<TreeSitterClientEvents> {
const callback = this.messageCallbacks.get(message.messageId)
if (callback) {
this.messageCallbacks.delete(message.messageId)
callback({ error: message.error })
callback.resolve({ error: message.error })
}
return
}
@@ -402,7 +514,7 @@ export class TreeSitterClient extends EventEmitter<TreeSitterClientEvents> {
const callback = this.messageCallbacks.get(message.messageId)
if (callback) {
this.messageCallbacks.delete(message.messageId)
callback({ error: message.error })
callback.resolve({ error: message.error })
}
return
}
@@ -426,13 +538,18 @@ export class TreeSitterClient extends EventEmitter<TreeSitterClientEvents> {
public async preloadParser(filetype: string): Promise<boolean> {
const messageId = `has_parser_${this.messageIdCounter++}`
const response = await new Promise<{ hasParser: boolean; warning?: string; error?: string }>((resolve) => {
this.messageCallbacks.set(messageId, resolve)
this.sendWorkerMessage({
type: "PRELOAD_PARSER",
filetype,
messageId,
})
const response = await new Promise<{ hasParser: boolean; warning?: string; error?: string }>((resolve, reject) => {
this.messageCallbacks.set(messageId, { resolve, reject })
try {
this.sendWorkerMessage({
type: "PRELOAD_PARSER",
filetype,
messageId,
})
} catch (error) {
this.messageCallbacks.delete(messageId)
reject(error instanceof Error ? error : new Error(String(error)))
}
})
return response.hasParser
}
@@ -465,16 +582,21 @@ export class TreeSitterClient extends EventEmitter<TreeSitterClientEvents> {
this.buffers.set(id, { id, content, filetype, version, hasParser: false })
const messageId = `init_${this.messageIdCounter++}`
const response = await new Promise<{ hasParser: boolean; warning?: string; error?: string }>((resolve) => {
this.messageCallbacks.set(messageId, resolve)
this.sendWorkerMessage({
type: "INITIALIZE_PARSER",
bufferId: id,
version,
content,
filetype,
messageId,
})
const response = await new Promise<{ hasParser: boolean; warning?: string; error?: string }>((resolve, reject) => {
this.messageCallbacks.set(messageId, { resolve, reject })
try {
this.sendWorkerMessage({
type: "INITIALIZE_PARSER",
bufferId: id,
version,
content,
filetype,
messageId,
})
} catch (error) {
this.messageCallbacks.delete(messageId)
reject(error instanceof Error ? error : new Error(String(error)))
}
})
if (!response.hasParser) {
@@ -548,9 +670,9 @@ export class TreeSitterClient extends EventEmitter<TreeSitterClientEvents> {
}
if (this.worker) {
await new Promise<boolean>((resolve) => {
await new Promise<boolean>((resolve, reject) => {
const messageId = `dispose_${bufferId}`
this.messageCallbacks.set(messageId, resolve)
this.messageCallbacks.set(messageId, { resolve, reject })
try {
this.sendWorkerMessage({
type: "DISPOSE_BUFFER",
@@ -558,6 +680,7 @@ export class TreeSitterClient extends EventEmitter<TreeSitterClientEvents> {
})
} catch (error) {
console.error("Error disposing buffer", error)
this.messageCallbacks.delete(messageId)
resolve(false)
}
@@ -575,7 +698,26 @@ export class TreeSitterClient extends EventEmitter<TreeSitterClientEvents> {
this.debouncer.clearDebounce(`reset-${bufferId}`)
}
public async destroy(): Promise<void> {
public destroy(): Promise<void> {
if (this.destroyPromise) {
return this.destroyPromise
}
let resolveDestroy!: () => void
let rejectDestroy!: (error: unknown) => void
const destroyPromise = new Promise<void>((resolve, reject) => {
resolveDestroy = resolve
rejectDestroy = reject
})
this.destroyPromise = destroyPromise
const destroyError = new Error("Client destroyed during initialization")
this.lifecycleGeneration++
this.initialized = false
this.initializePromise = undefined
this.rejectActiveInitialization(destroyError)
this.rejectPendingRequests(new Error("TreeSitter client destroyed"))
for (const callback of this.destroyCallbacks) {
try {
callback()
@@ -585,34 +727,29 @@ export class TreeSitterClient extends EventEmitter<TreeSitterClientEvents> {
}
this.destroyCallbacks.clear()
if (this.initializeResolvers) {
clearTimeout(this.initializeResolvers.timeoutId)
// Reject pending initialization promise to prevent hanging awaits
this.initializeResolvers.reject(new Error("Client destroyed during initialization"))
this.initializeResolvers = undefined
}
for (const [messageId, callback] of this.messageCallbacks.entries()) {
if (typeof callback === "function") {
try {
callback({ error: "Client destroyed" })
} catch (e) {
// Ignore errors during cleanup
}
}
}
this.messageCallbacks.clear()
clearDebounceScope("tree-sitter-client")
this.debouncer.clear()
this.editQueues.clear()
this.buffers.clear()
await this.stopWorker()
this.initialized = false
this.initializePromise = undefined
void this.stopWorker().then(
() => {
this.workerTerminationFailed = false
if (this.destroyPromise === destroyPromise) {
this.destroyPromise = undefined
}
resolveDestroy()
},
(error) => {
this.workerTerminationFailed = true
if (this.destroyPromise === destroyPromise) {
this.destroyPromise = undefined
}
rejectDestroy(error)
},
)
return destroyPromise
}
public async resetBuffer(bufferId: number, version: number, content: string): Promise<void> {
@@ -655,18 +792,26 @@ export class TreeSitterClient extends EventEmitter<TreeSitterClientEvents> {
if (this.initialized && this.worker) {
const messageId = `update_datapath_${this.messageIdCounter++}`
return new Promise<void>((resolve, reject) => {
this.messageCallbacks.set(messageId, (response: any) => {
if (response.error) {
reject(new Error(response.error))
} else {
resolve()
}
})
this.sendWorkerMessage({
type: "UPDATE_DATA_PATH",
dataPath,
messageId,
this.messageCallbacks.set(messageId, {
resolve: (response: any) => {
if (response.error) {
reject(new Error(response.error))
} else {
resolve()
}
},
reject,
})
try {
this.sendWorkerMessage({
type: "UPDATE_DATA_PATH",
dataPath,
messageId,
})
} catch (error) {
this.messageCallbacks.delete(messageId)
reject(error instanceof Error ? error : new Error(String(error)))
}
})
}
}
@@ -678,17 +823,25 @@ export class TreeSitterClient extends EventEmitter<TreeSitterClientEvents> {
const messageId = `clear_cache_${this.messageIdCounter++}`
return new Promise<void>((resolve, reject) => {
this.messageCallbacks.set(messageId, (response: any) => {
if (response.error) {
reject(new Error(response.error))
} else {
resolve()
}
})
this.sendWorkerMessage({
type: "CLEAR_CACHE",
messageId,
this.messageCallbacks.set(messageId, {
resolve: (response: any) => {
if (response.error) {
reject(new Error(response.error))
} else {
resolve()
}
},
reject,
})
try {
this.sendWorkerMessage({
type: "CLEAR_CACHE",
messageId,
})
} catch (error) {
this.messageCallbacks.delete(messageId)
reject(error instanceof Error ? error : new Error(String(error)))
}
})
}
}
@@ -0,0 +1,12 @@
import { postWorkerMessage, setWorkerMessageHandler } from "./worker.js"
const cleanupFirst = setWorkerMessageHandler(() => {})
const cleanupSecond = setWorkerMessageHandler(() => {})
cleanupFirst()
cleanupSecond()
postWorkerMessage({
type: "HANDLERS_CLEANED",
handlerCleared: (globalThis as { onmessage?: unknown }).onmessage == null,
})
@@ -0,0 +1,10 @@
import { postWorkerMessage } from "./worker.js"
postWorkerMessage({ type: "WAITING_FOR_MESSAGE" })
await new Promise<void>((resolve) => {
;(globalThis as unknown as { onmessage?: (event: { data: number }) => void }).onmessage = (event) => {
postWorkerMessage({ type: "RECEIVED", value: event.data })
resolve()
}
})
@@ -0,0 +1,9 @@
import { postWorkerMessage, setWorkerMessageHandler } from "./worker.js"
postWorkerMessage({ type: "IMPORT_STARTED" })
await new Promise((resolve) => setTimeout(resolve, 50))
setWorkerMessageHandler<number>((event) => {
postWorkerMessage({ type: "RECEIVED", value: event.data })
})
@@ -0,0 +1,153 @@
import { expect, test } from "bun:test"
import { Worker } from "./worker.js"
test("Node worker retains messages posted while its module is loading", async () => {
const worker = new Worker(new URL("./worker-startup.fixture.js", import.meta.url))
const received: number[] = []
try {
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error("Worker startup test timed out")), 2_000)
worker.onerror = (event) => {
clearTimeout(timeout)
reject(event.error ?? new Error(event.message))
}
worker.onmessage = (event) => {
const message = event.data as { type: string; value?: number }
if (message.type === "IMPORT_STARTED") {
worker.postMessage(1)
worker.postMessage(2)
worker.postMessage(3)
return
}
if (message.type === "RECEIVED") {
received.push(message.value!)
if (received.length === 3) {
clearTimeout(timeout)
resolve()
}
}
}
})
expect(received).toEqual([1, 2, 3])
} finally {
await worker.terminate()
}
})
test("Node worker delivers startup messages to a web-style handler during module evaluation", async () => {
const worker = new Worker(new URL("./worker-onmessage-startup.fixture.js", import.meta.url))
try {
const value = await new Promise<number>((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error("Web-style worker startup test timed out")), 2_000)
worker.onerror = (event) => {
clearTimeout(timeout)
reject(event.error ?? new Error(event.message))
}
worker.onmessage = (event) => {
const message = event.data as { type: string; value?: number }
if (message.type === "WAITING_FOR_MESSAGE") {
worker.postMessage(42)
return
}
if (message.type === "RECEIVED") {
clearTimeout(timeout)
resolve(message.value!)
}
}
})
expect(value).toBe(42)
} finally {
await worker.terminate()
}
})
test("Node worker does not restore a disposed message handler", async () => {
const worker = new Worker(new URL("./worker-handler-cleanup.fixture.js", import.meta.url))
try {
const handlerCleared = await new Promise<boolean>((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error("Worker handler cleanup test timed out")), 2_000)
worker.onerror = (event) => {
clearTimeout(timeout)
reject(event.error ?? new Error(event.message))
}
worker.onmessage = (event) => {
const message = event.data as { type: string; handlerCleared?: boolean }
if (message.type === "HANDLERS_CLEANED") {
clearTimeout(timeout)
resolve(message.handlerCleared!)
}
}
})
expect(handlerCleared).toBe(true)
} finally {
await worker.terminate()
}
})
test("Node worker restores transport listeners when termination fails", async () => {
const worker = new Worker(new URL("./worker-startup.fixture.js", import.meta.url))
const nativeWorker = (
worker as unknown as {
worker: {
listenerCount: (event: "message" | "error") => number
terminate: () => Promise<number>
}
}
).worker
const originalTerminate = nativeWorker.terminate.bind(nativeWorker)
nativeWorker.terminate = async () => {
throw new Error("synthetic termination failure")
}
try {
await expect(worker.terminate()).rejects.toThrow("synthetic termination failure")
expect(nativeWorker.listenerCount("message")).toBe(1)
expect(nativeWorker.listenerCount("error")).toBe(1)
} finally {
nativeWorker.terminate = originalTerminate
await worker.terminate()
}
})
test("Node worker shares concurrent termination attempts", async () => {
const worker = new Worker(new URL("./worker-startup.fixture.js", import.meta.url))
const nativeWorker = (
worker as unknown as {
worker: {
listenerCount: (event: "message" | "error") => number
terminate: () => Promise<number>
}
}
).worker
const originalTerminate = nativeWorker.terminate.bind(nativeWorker)
let terminationCount = 0
nativeWorker.terminate = async () => {
terminationCount++
throw new Error("synthetic concurrent termination failure")
}
try {
const first = worker.terminate()
const second = worker.terminate()
expect(first).toBe(second)
await expect(first).rejects.toThrow("synthetic concurrent termination failure")
expect(terminationCount).toBe(1)
expect(nativeWorker.listenerCount("message")).toBe(1)
expect(nativeWorker.listenerCount("error")).toBe(1)
} finally {
nativeWorker.terminate = originalTerminate
await worker.terminate()
}
})
+75 -4
View File
@@ -67,6 +67,7 @@ interface WorkerRuntimeBridge {
type GlobalWithWorker = typeof globalThis & {
Worker?: PlatformWorkerConstructor
__opentuiWorkerMessageBridge?: true
close?: () => void
postMessage?: (value: unknown) => void
}
@@ -169,6 +170,7 @@ function createNodeWorkerConstructor(node: NodeWorkerThreadsModule): PlatformWor
private readonly errorListeners = new Set<WorkerErrorHandler>()
private readonly messageListeners = new Set<WorkerMessageHandler>()
private readonly worker: NodeWorkerThread
private terminationPromise: Promise<number> | undefined
constructor(specifier: string | URL, options: PlatformWorkerOptions = {}) {
const resolvedSpecifier = resolveWorkerImportSpecifier(specifier)
@@ -188,9 +190,20 @@ function createNodeWorkerConstructor(node: NodeWorkerThreadsModule): PlatformWor
}
terminate(): Promise<number> {
if (this.terminationPromise) {
return this.terminationPromise
}
this.worker.off("message", this.handleMessage)
this.worker.off("error", this.handleError)
return this.worker.terminate()
const termination = this.worker.terminate().catch((error: unknown) => {
this.terminationPromise = undefined
this.worker.on("message", this.handleMessage)
this.worker.on("error", this.handleError)
throw error
})
this.terminationPromise = termination
return termination
}
addEventListener(type: "message" | "error", listener: WorkerMessageHandler | WorkerErrorHandler): void {
@@ -240,10 +253,31 @@ function createWorkerBootstrapSource(specifier: string): string {
return `
import { parentPort } from "node:worker_threads"
const pendingMessages = []
let messageHandler = null
globalThis.self ??= globalThis
globalThis.postMessage ??= (value) => parentPort?.postMessage(value)
globalThis.__opentuiWorkerMessageBridge = true
Object.defineProperty(globalThis, "onmessage", {
configurable: true,
get: () => messageHandler,
set: (handler) => {
messageHandler = typeof handler === "function" ? handler : null
if (!messageHandler) return
const messages = pendingMessages.splice(0)
for (const data of messages) {
messageHandler({ data })
}
},
})
parentPort?.on("message", (data) => {
globalThis.onmessage?.({ data })
if (messageHandler) {
messageHandler({ data })
} else {
pendingMessages.push(data)
}
})
await import(${JSON.stringify(specifier)})
@@ -285,6 +319,10 @@ function isRuntimeSpecifier(specifier: string): boolean {
function loadWorkerRuntime(node: NodeWorkerThreadsModule | undefined): WorkerRuntimeBridge | undefined {
if (node?.parentPort && node.isMainThread === false) {
if (globalWithWorker.__opentuiWorkerMessageBridge) {
return createGlobalWorkerRuntimeBridge()
}
return {
postMessage(value: unknown): void {
node.parentPort?.postMessage(value)
@@ -307,23 +345,56 @@ function loadWorkerRuntime(node: NodeWorkerThreadsModule | undefined): WorkerRun
return undefined
}
return createGlobalWorkerRuntimeBridge()
}
function createGlobalWorkerRuntimeBridge(): WorkerRuntimeBridge {
interface HandlerRegistration {
active: boolean
fallbackHandler: AnyWorkerMessageHandler | null
listener: AnyWorkerMessageHandler
previous?: HandlerRegistration
}
let currentRegistration: HandlerRegistration | undefined
return {
postMessage(value: unknown): void {
globalWithWorker.postMessage?.(value)
},
setMessageHandler<T>(handler: WorkerMessageHandler<T>): () => void {
const previousHandler = getGlobalWorkerMessageHandler()
if (currentRegistration && previousHandler !== currentRegistration.listener) {
currentRegistration = undefined
}
const listener: WorkerMessageHandler<T> = (event): void => {
const normalizedEvent = normalizeWorkerMessageEvent<T>(event)
void handler(normalizedEvent)
}
const registration: HandlerRegistration = {
active: true,
fallbackHandler: currentRegistration ? currentRegistration.fallbackHandler : previousHandler,
listener,
previous: currentRegistration,
}
currentRegistration = registration
setGlobalWorkerMessageHandler(listener)
return () => {
if (getGlobalWorkerMessageHandler() === listener) {
setGlobalWorkerMessageHandler(previousHandler)
registration.active = false
if (currentRegistration !== registration || getGlobalWorkerMessageHandler() !== listener) {
return
}
let previous = registration.previous
while (previous && !previous.active) {
previous = previous.previous
}
currentRegistration = previous
setGlobalWorkerMessageHandler(previous?.listener ?? registration.fallbackHandler)
}
},
}
-1
View File
@@ -4245,7 +4245,6 @@ export class CliRenderer extends EventEmitter implements RenderContext {
}
rendererTracker.renderers.delete(this)
if (rendererTracker.renderers.size === 0) {
process.stdin.pause()
void destroyTreeSitterClient().catch((error) => {
console.error("Failed to destroy tree-sitter client:", error)
})
@@ -159,3 +159,44 @@ test("renderer with custom stdin does not pause process.stdin on destroy", async
expect(pauseCalled).toBe(false)
})
test("destroying process stdin owner pauses it while a custom renderer remains", async () => {
const processRenderer = await createCliRenderer({
stdin: process.stdin,
stdout: createTestStdout(),
bufferedOutput: "memory",
})
destroyFns.push(() => processRenderer.destroy())
const customRenderer = await createCliRenderer({
stdin: createTestStdin(),
stdout: createTestStdout(),
bufferedOutput: "memory",
})
destroyFns.push(() => customRenderer.destroy())
pauseCalled = false
processRenderer.destroy()
expect(pauseCalled).toBe(true)
})
test("destroying final custom renderer does not pause process stdin again", async () => {
const processRenderer = await createCliRenderer({
stdin: process.stdin,
stdout: createTestStdout(),
bufferedOutput: "memory",
})
destroyFns.push(() => processRenderer.destroy())
const customRenderer = await createCliRenderer({
stdin: createTestStdin(),
stdout: createTestStdout(),
bufferedOutput: "memory",
})
destroyFns.push(() => customRenderer.destroy())
processRenderer.destroy()
pauseCalled = false
customRenderer.destroy()
expect(pauseCalled).toBe(false)
})
+4
View File
@@ -8,6 +8,10 @@
"include": [
"src/platform/ffi.test.ts",
"src/platform/runtime.test.ts",
"src/platform/worker.node-test.ts",
"src/platform/worker-startup.fixture.ts",
"src/platform/worker-onmessage-startup.fixture.ts",
"src/platform/worker-handler-cleanup.fixture.ts",
"src/lib/bunfs.test.ts",
"src/lib/border.test.ts",
"src/lib/clipboard.test.ts",