mirror of
https://github.com/anomalyco/opentui.git
synced 2026-09-19 01:26:03 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 })
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user