diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e456b3d8..d2bfe30b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -124,6 +124,9 @@ jobs: .\bindings\py\.venv\Scripts\python.exe -m mypy.stubtest dynwinrt_py --allowlist bindings\py\stubtest_allowlist.txt --ignore-disjoint-bases - name: Run E2E tests run: .\tests\e2e\e2e_test.ps1 -SkipBuild + - name: Test JS TSFN lifecycle and queue handling + working-directory: bindings/js + run: npm run test:tsfn # dynwinrt-codegen (x64 + arm64) dynwinrt-codegen: diff --git a/bindings/js/Cargo.toml b/bindings/js/Cargo.toml index 9ce3af6a..d7f11c06 100644 --- a/bindings/js/Cargo.toml +++ b/bindings/js/Cargo.toml @@ -11,6 +11,9 @@ repository = "https://github.com/microsoft/dynwinrt" [lib] crate-type = ["cdylib"] +[features] +test-hooks = [] + [dependencies] napi = { version = "3", features = ["napi7"] } napi-derive = "3.0.0" diff --git a/bindings/js/__test__/index.spec.ts b/bindings/js/__test__/index.spec.ts index 21a008bc..c800b723 100644 --- a/bindings/js/__test__/index.spec.ts +++ b/bindings/js/__test__/index.spec.ts @@ -924,9 +924,19 @@ if (missingWinuiFixtures.length > 0) { test('round-trip WinRT values', (t) => { t.is(DynWinRtValue.i32(42).toNumber(), 42) + t.is(DynWinRtValue.u32(0xffffffff).toNumber(), 0xffffffff) t.is(DynWinRtValue.i64(-42n).toI64Bigint(), -42n) t.is(DynWinRtValue.u64(2n ** 63n).toU64Bigint(), 2n ** 63n) t.is(DynWinRtValue.u64(42).toI64(), 42) + t.throws(() => DynWinRtValue.i64(9_007_199_254_740_992n).toI64(), { + message: /safe-integer range.*bigint conversion/, + }) + t.throws(() => DynWinRtValue.u64(9_007_199_254_740_992n).toI64(), { + message: /safe-integer range.*bigint conversion/, + }) + t.throws(() => DynWinRtValue.u64(2n ** 63n).toI64(), { + message: /greater than i64::MAX/, + }) t.throws(() => DynWinRtValue.u64(-1), { message: /non-negative safe integer/, }) @@ -941,6 +951,81 @@ test('round-trip WinRT values', (t) => { t.true(DynWinRtValue.nullValue().isNull()) }) +test('invalid WinRT value conversions throw JavaScript errors', (t) => { + const value = DynWinRtValue.hstring('not numeric') + + t.throws(() => value.toNumber(), { message: /Cannot convert.*to number/ }) + t.throws(() => value.toBool(), { message: /Cannot convert.*to number/ }) + t.throws(() => value.toI64(), { message: /Cannot convert.*to number/ }) + t.throws(() => value.toF64(), { message: /Cannot convert.*to number/ }) + t.throws(() => value.asRaw(), { message: /non-object/ }) + t.throws(() => DynCom.toNumber(value), { message: /Cannot convert.*to number/ }) +}) + +test('u64 arrays and struct fields preserve the full unsigned range', (t) => { + const values = [0n, 2n ** 63n, 2n ** 64n - 1n] + const array = DynWinRtArray.fromU64Values(values) + t.deepEqual(array.toU64Vec(), values) + t.deepEqual(DynWinRtArray.fromU64Values([42]).toU64Vec(), [42n]) + t.throws(() => DynWinRtArray.fromU64Values([-1]), { + message: /non-negative safe integer/, + }) + t.throws(() => DynWinRtArray.fromU64Values([2n ** 64n]), { + message: /fit in an unsigned 64-bit integer/, + }) + + const structType = DynWinRtType.structType('DynWinRT.Tests.IntegerBoundary', [ + DynWinRtType.u64(), + DynWinRtType.i64(), + ]) + const value = DynWinRtStruct.create(structType) + value.setU64(0, 2n ** 64n - 1n) + t.is(value.getU64(0), 2n ** 64n - 1n) + t.throws(() => value.setU64(0, -1n), { + message: /fit in an unsigned 64-bit integer/, + }) + value.setI64(1, -(2n ** 63n)) + t.is(value.getI64(1), -(2n ** 63n)) + t.throws(() => value.setI64(1, 2n ** 63n), { + message: /fit in a signed 64-bit integer/, + }) + + const signedValues = [-(2n ** 63n), 0n, 2n ** 63n - 1n] + t.deepEqual(DynWinRtArray.fromI64Values(signedValues).toI64Vec(), signedValues) + t.throws(() => DynWinRtArray.fromI64Values([2n ** 63n]), { + message: /fit in a signed 64-bit integer/, + }) +}) + +test('struct field access rejects invalid indexes, types, ranges, and identities', (t) => { + t.throws(() => DynWinRtStruct.create(DynWinRtType.i32()), { + message: /requires a struct type/, + }) + + const scalarType = DynWinRtType.structType('DynWinRT.Tests.ValidatedScalarFields', [ + DynWinRtType.i32(), + DynWinRtType.u8(), + ]) + const scalar = DynWinRtStruct.create(scalarType) + t.throws(() => scalar.getI32(2), { message: /out of bounds/ }) + t.throws(() => scalar.getI32(2 ** 32), { message: /u32 range/ }) + t.throws(() => scalar.getI32(1.9), { message: /integer in the u32 range/ }) + t.throws(() => scalar.getU64(0), { message: /expected u64/ }) + t.throws(() => scalar.setU8(1, 256), { message: /outside the u8 range/ }) + t.throws(() => scalar.setU8(1, 2 ** 32), { message: /u32 range/ }) + t.throws(() => scalar.setU8(1, 1.9), { message: /integer in the u32 range/ }) + t.throws(() => scalar.setI32(0, 2 ** 32), { message: /i32 range/ }) + + const innerAType = DynWinRtType.structType('DynWinRT.Tests.InnerA', [DynWinRtType.i32()]) + const innerBType = DynWinRtType.structType('DynWinRT.Tests.InnerB', [DynWinRtType.i32()]) + const outerType = DynWinRtType.structType('DynWinRT.Tests.Outer', [innerAType]) + const outer = DynWinRtStruct.create(outerType) + const innerB = DynWinRtStruct.create(innerBType) + t.throws(() => outer.setStruct(0, innerB), { + message: /requires Struct.*found Struct/, + }) +}) + test('box IReference values', (t) => { const valueType = DynWinRtType.u32() const referenceType = DynWinRtType.parameterized(WinGuid.parse('61c17706-2d65-11e0-9ae8-d48564015472'), [valueType]) @@ -992,10 +1077,16 @@ test('release WinRT object values deterministically', (t) => { test('round-trip WinRT value arrays', (t) => { const array = DynWinRtArray.fromI32Values([1, 2, 3]) t.is(array.len(), 3) + t.deepEqual(array.toI32Vec(), [1, 2, 3]) t.deepEqual( array.toValues().map((value) => value.toNumber()), [1, 2, 3], ) + t.deepEqual([...DynWinRtArray.fromU8Values([1, 2, 3]).toBuffer()], [1, 2, 3]) + t.throws(() => array.toU64Vec(), { message: /element type I32/ }) + t.throws(() => array.get(3), { message: /out of bounds/ }) + t.throws(() => array.get(2 ** 32), { message: /u32 range/ }) + t.throws(() => array.get(1.9), { message: /integer in the u32 range/ }) }) test('create empty vectors for large struct element types', (t) => { diff --git a/bindings/js/__test__/tsfn-exception-child.mjs b/bindings/js/__test__/tsfn-exception-child.mjs new file mode 100644 index 00000000..18c94a89 --- /dev/null +++ b/bindings/js/__test__/tsfn-exception-child.mjs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { createRequire } from 'node:module' + +const runtime = createRequire(import.meta.url)(process.env.DYNWINRT_TEST_RUNTIME) +const timeout = setTimeout(() => { + console.error('TSFN callback exception was not observed') + process.exit(1) +}, 2_000) + +process.once('uncaughtException', (error) => { + clearTimeout(timeout) + console.log(`tsfn-uncaught:${error.message}`) + process.exit(0) +}) + +runtime.tsfnTestStartUnbounded(() => { + throw new Error('TSFN callback failure') +}, 1, 0) diff --git a/bindings/js/__test__/tsfn-liveness-child.mjs b/bindings/js/__test__/tsfn-liveness-child.mjs new file mode 100644 index 00000000..77a11775 --- /dev/null +++ b/bindings/js/__test__/tsfn-liveness-child.mjs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { createRequire } from 'node:module' + +const runtime = createRequire(import.meta.url)(process.env.DYNWINRT_TEST_RUNTIME) +const mode = process.argv[2] + +if (mode === 'strong') { + runtime.tsfnTestHoldStrong(() => {}) + const release = setTimeout(() => { + runtime.tsfnTestReleaseHeld() + console.log('strong-release-fired') + }, 300) + release.unref() +} else if (mode === 'weak') { + runtime.tsfnTestHoldWeak(() => {}) + process.once('beforeExit', () => { + runtime.tsfnTestReleaseHeld() + console.log('weak-exited-without-timer') + }) + const shouldNotFire = setTimeout(() => { + console.error('weak TSFN kept the event loop alive') + process.exit(1) + }, 1_000) + shouldNotFire.unref() +} else { + throw new Error(`Unknown TSFN liveness mode: ${mode}`) +} diff --git a/bindings/js/__test__/tsfn-teardown-child.mjs b/bindings/js/__test__/tsfn-teardown-child.mjs new file mode 100644 index 00000000..67758a0f --- /dev/null +++ b/bindings/js/__test__/tsfn-teardown-child.mjs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import assert from 'node:assert/strict' +import { createRequire } from 'node:module' +import { Worker } from 'node:worker_threads' + +const runtimePath = process.env.DYNWINRT_TEST_RUNTIME +const runtime = createRequire(import.meta.url)(runtimePath) +runtime.tsfnTestReset() + +const worker = new Worker(new URL('./tsfn-teardown-worker.mjs', import.meta.url), { + workerData: { runtimePath }, +}) +const queued = await new Promise((resolve, reject) => { + worker.once('message', resolve) + worker.once('error', reject) +}) +assert.equal(queued.produced, 10_000) +assert.equal(queued.accepted, 10_000) +assert.equal(queued.dropped, 0) + +await worker.terminate() +await new Promise((resolve) => setTimeout(resolve, 100)) + +const stats = runtime.tsfnTestStats() +console.log(JSON.stringify(stats)) +assert.equal(stats.produced, 10_000) +assert.equal(stats.dropped, stats.produced) diff --git a/bindings/js/__test__/tsfn-teardown-worker.mjs b/bindings/js/__test__/tsfn-teardown-worker.mjs new file mode 100644 index 00000000..26dc04c6 --- /dev/null +++ b/bindings/js/__test__/tsfn-teardown-worker.mjs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { createRequire } from 'node:module' +import { parentPort, workerData } from 'node:worker_threads' + +const runtime = createRequire(import.meta.url)(workerData.runtimePath) +const count = 10_000 +runtime.tsfnTestStartUnbounded(() => {}, count, 0) +if (!runtime.tsfnTestWaitProduced(count, 2_000)) { + throw new Error('TSFN producer did not enqueue every payload before the timeout') +} +parentPort.postMessage(runtime.tsfnTestStats()) + +Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5_000) diff --git a/bindings/js/__test__/tsfn-worker-delegate-child.mjs b/bindings/js/__test__/tsfn-worker-delegate-child.mjs new file mode 100644 index 00000000..ac2e8bb8 --- /dev/null +++ b/bindings/js/__test__/tsfn-worker-delegate-child.mjs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import assert from 'node:assert/strict' +import { createRequire } from 'node:module' +import { Worker } from 'node:worker_threads' + +const runtimePath = process.env.DYNWINRT_TEST_RUNTIME +const runtime = createRequire(import.meta.url)(runtimePath) +const worker = new Worker(new URL('./tsfn-worker-delegate-worker.mjs', import.meta.url), { + workerData: { runtimePath }, +}) + +await new Promise((resolve, reject) => { + worker.once('message', resolve) + worker.once('error', reject) +}) +runtime.tsfnTestArmCallPause() +runtime.tsfnTestStartRetainedDelegateStress(1) +assert.equal(runtime.tsfnTestWaitCallPaused(2_000), true) +const termination = worker.terminate() +assert.equal(runtime.tsfnTestWaitCleanupWaiting(2_000), true) +assert.equal(runtime.tsfnTestCleanupAcquired(), false) +runtime.tsfnTestReleaseCallPause() +await termination +assert.equal(runtime.tsfnTestCleanupAcquired(), true) +const stress = runtime.tsfnTestWaitRetainedDelegateStress(5_000) +assert.equal(stress.succeeded + stress.failed, 1) +await new Promise((resolve) => setTimeout(resolve, 50)) + +const hr = runtime.tsfnTestInvokeRetainedDelegate() +console.log(`late-delegate-hr:${hr}`) +assert.equal(hr, 0x80004005 | 0) +runtime.tsfnTestReleaseRetainedDelegate() diff --git a/bindings/js/__test__/tsfn-worker-delegate-worker.mjs b/bindings/js/__test__/tsfn-worker-delegate-worker.mjs new file mode 100644 index 00000000..613805dd --- /dev/null +++ b/bindings/js/__test__/tsfn-worker-delegate-worker.mjs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { createRequire } from 'node:module' +import { parentPort, workerData } from 'node:worker_threads' + +const runtime = createRequire(import.meta.url)(workerData.runtimePath) +const delegate = runtime.DynWinRtDelegate.create( + runtime.WinGuid.parse('8b4e9f50-8a4c-4f10-9cc0-df934c32015f'), + [], + () => {}, +) +runtime.tsfnTestRetainDelegate(delegate) +parentPort.postMessage('retained') + +Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5_000) diff --git a/bindings/js/__test__/tsfn.spec.ts b/bindings/js/__test__/tsfn.spec.ts new file mode 100644 index 00000000..7e2efb57 --- /dev/null +++ b/bindings/js/__test__/tsfn.spec.ts @@ -0,0 +1,324 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import test from 'ava' +import { AsyncLocalStorage } from 'node:async_hooks' +import { spawn } from 'node:child_process' +import { createRequire } from 'node:module' +import { fileURLToPath } from 'node:url' + +interface TsfnStats { + produced: number + dropped: number + accepted: number + queueFull: number + closing: number + otherFailure: number +} + +interface TsfnDelegateInvokeStats { + succeeded: number + failed: number +} + +interface TsfnTestRuntime { + tsfnTestReset(): void + tsfnTestStats(): TsfnStats + tsfnTestStartUnbounded(callback: (id: number) => void, count: number, delayMs: number): void + tsfnTestStartBounded(callback: (id: number) => void, count: number, delayMs: number): void + tsfnTestWaitProduced(expected: number, timeoutMs: number): boolean + tsfnTestHoldStrong(callback: (id: number) => void): void + tsfnTestHoldWeak(callback: (id: number) => void): void + tsfnTestReleaseHeld(): void + tsfnTestRegisteredHandleCount(): number + tsfnTestRetainDelegate(delegate: unknown): void + tsfnTestInvokeRetainedDelegate(): number + tsfnTestInvokeRetainedDelegateOnThread(): number + tsfnTestInvokeRetainedDelegateOnThreadMany(count: number): TsfnDelegateInvokeStats + tsfnTestReleaseRetainedDelegate(): void +} + +const runtime = createRequire(import.meta.url)('../dist/index.js') as Partial +const hasTestHooks = typeof runtime.tsfnTestStartUnbounded === 'function' + +async function waitForDrops(expected: number): Promise { + const deadline = Date.now() + 2_000 + let stats = runtime.tsfnTestStats!() + while (stats.dropped < expected && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 5)) + stats = runtime.tsfnTestStats!() + } + return stats +} + +if (!hasTestHooks) { + test.skip('TSFN native test hooks require the test-hooks Cargo feature', () => {}) +} else { + test.serial('default TSFN queue accepts work before any JavaScript callback executes', async (t) => { + const count = 10_000 + let callbacks = 0 + runtime.tsfnTestReset!() + runtime.tsfnTestStartUnbounded!(() => { + callbacks += 1 + }, count, 0) + + t.true(runtime.tsfnTestWaitProduced!(count, 2_000)) + const queued = runtime.tsfnTestStats!() + t.is(queued.produced, count) + t.is(queued.accepted, count) + t.is(queued.queueFull, 0) + t.is(callbacks, 0) + t.is(queued.dropped, 0) + + const drained = await waitForDrops(count) + t.is(drained.dropped, count) + t.is(callbacks, count) + }) + + test.serial('bounded TSFN releases every payload rejected with QueueFull', async (t) => { + const count = 1_000 + runtime.tsfnTestReset!() + runtime.tsfnTestStartBounded!(() => {}, count, 0) + + t.true(runtime.tsfnTestWaitProduced!(count, 2_000)) + const drained = await waitForDrops(count) + t.true(drained.queueFull > 0) + t.is(drained.dropped, drained.produced) + }) + + test.serial('environment teardown releases every payload already queued in a TSFN', async (t) => { + const child = spawn( + process.execPath, + [fileURLToPath(new URL('./tsfn-teardown-child.mjs', import.meta.url))], + { + env: { + ...process.env, + DYNWINRT_TEST_RUNTIME: fileURLToPath(new URL('../dist/index.js', import.meta.url)), + }, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }, + ) + let stdout = '' + let stderr = '' + child.stdout.on('data', (chunk) => { + stdout += String(chunk) + }) + child.stderr.on('data', (chunk) => { + stderr += String(chunk) + }) + const code = await new Promise((resolve) => { + child.once('close', resolve) + }) + + t.regex(stdout, /"produced":10000/) + t.is(code, 0, `${stdout}\n${stderr}`) + }) + + test.serial('a thrown TSFN callback reaches uncaughtException', async (t) => { + const child = spawn( + process.execPath, + [fileURLToPath(new URL('./tsfn-exception-child.mjs', import.meta.url))], + { + env: { + ...process.env, + DYNWINRT_TEST_RUNTIME: fileURLToPath(new URL('../dist/index.js', import.meta.url)), + }, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }, + ) + let stdout = '' + let stderr = '' + child.stdout.on('data', (chunk) => { + stdout += String(chunk) + }) + child.stderr.on('data', (chunk) => { + stderr += String(chunk) + }) + const code = await new Promise((resolve) => { + child.once('close', resolve) + }) + + t.is(code, 0, stderr) + t.regex(stdout, /tsfn-uncaught:TSFN callback failure/) + }) + + for (const [mode, expectedMarker] of [ + ['strong', 'strong-release-fired'], + ['weak', 'weak-exited-without-timer'], + ] as const) { + test.serial(`${mode} TSFN has the expected event-loop liveness`, async (t) => { + const started = Date.now() + const child = spawn( + process.execPath, + [fileURLToPath(new URL('./tsfn-liveness-child.mjs', import.meta.url)), mode], + { + env: { + ...process.env, + DYNWINRT_TEST_RUNTIME: fileURLToPath(new URL('../dist/index.js', import.meta.url)), + }, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }, + ) + let stdout = '' + let stderr = '' + child.stdout.on('data', (chunk) => { + stdout += String(chunk) + }) + child.stderr.on('data', (chunk) => { + stderr += String(chunk) + }) + const code = await new Promise((resolve) => { + child.once('close', resolve) + }) + const elapsed = Date.now() - started + + t.is(code, 0, stderr) + t.regex(stdout, new RegExp(expectedMarker)) + if (mode === 'strong') { + t.true(elapsed >= 200, `strong TSFN exited after only ${elapsed} ms`) + } else { + t.true(elapsed < 1_000, `weak TSFN took ${elapsed} ms to exit`) + } + }) + } + + test.serial('short-lived TSFNs unregister from the per-environment registry', async (t) => { + runtime.tsfnTestReset!() + for (let index = 0; index < 100; index += 1) { + runtime.tsfnTestStartUnbounded!(() => {}, 1, 0) + } + t.true(runtime.tsfnTestWaitProduced!(100, 2_000)) + await waitForDrops(100) + const deadline = Date.now() + 2_000 + while (runtime.tsfnTestRegisteredHandleCount!() !== 0 && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 5)) + } + t.is(runtime.tsfnTestRegisteredHandleCount!(), 0) + }) + + test.serial('a delegate retained past Worker teardown fails late invocation without crashing', async (t) => { + const child = spawn( + process.execPath, + [fileURLToPath(new URL('./tsfn-worker-delegate-child.mjs', import.meta.url))], + { + env: { + ...process.env, + DYNWINRT_TEST_RUNTIME: fileURLToPath(new URL('../dist/index.js', import.meta.url)), + }, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }, + ) + let stdout = '' + let stderr = '' + child.stdout.on('data', (chunk) => { + stdout += String(chunk) + }) + child.stderr.on('data', (chunk) => { + stderr += String(chunk) + }) + const code = await new Promise((resolve) => { + child.once('close', resolve) + }) + + t.is(code, 0, `${stdout}\n${stderr}`) + t.regex(stdout, /late-delegate-hr:-2147467259/) + }) + + test.serial('same-thread delegate dispatch preserves AsyncLocalStorage context', async (t) => { + const native = runtime as TsfnTestRuntime & { + DynWinRtDelegate: { + create(iid: unknown, paramTypes: unknown[], callback: () => void): unknown + } + WinGuid: { + parse(value: string): unknown + } + } + const storage = new AsyncLocalStorage() + let observed: string | undefined + const delegate = storage.run('tsfn-context', () => + native.DynWinRtDelegate.create( + native.WinGuid.parse('45396ba0-cd24-42d4-9685-6863e032d69d'), + [], + () => { + observed = storage.getStore() + }, + ), + ) + native.tsfnTestRetainDelegate(delegate) + + await new Promise((resolve, reject) => { + setImmediate(() => { + try { + t.is(storage.getStore(), undefined) + t.is(native.tsfnTestInvokeRetainedDelegate(), 0) + native.tsfnTestReleaseRetainedDelegate() + resolve() + } catch (error) { + reject(error) + } + }) + }) + + t.is(observed, 'tsfn-context') + }) + + test.serial('cross-thread delegate S_OK means queued, not executed', async (t) => { + const native = runtime as TsfnTestRuntime & { + DynWinRtDelegate: { + create(iid: unknown, paramTypes: unknown[], callback: () => void): unknown + } + WinGuid: { + parse(value: string): unknown + } + } + let fired = false + const delegate = native.DynWinRtDelegate.create( + native.WinGuid.parse('bf33f101-7383-449f-9ad1-d76e960c9aac'), + [], + () => { + fired = true + }, + ) + native.tsfnTestRetainDelegate(delegate) + + t.is(native.tsfnTestInvokeRetainedDelegateOnThread(), 0) + t.false(fired) + await new Promise((resolve) => setImmediate(resolve)) + t.true(fired) + native.tsfnTestReleaseRetainedDelegate() + }) + + test.serial('production delegate TSFN applies a finite queue limit', async (t) => { + const native = runtime as TsfnTestRuntime & { + DynWinRtDelegate: { + create(iid: unknown, paramTypes: unknown[], callback: () => void): unknown + } + WinGuid: { + parse(value: string): unknown + } + } + let callbacks = 0 + const delegate = native.DynWinRtDelegate.create( + native.WinGuid.parse('e2923908-eac9-44f4-b05a-891a84728a18'), + [], + () => { + callbacks += 1 + }, + ) + native.tsfnTestRetainDelegate(delegate) + + const results = native.tsfnTestInvokeRetainedDelegateOnThreadMany(1_030) + t.is(results.succeeded, 1_024) + t.is(results.failed, 6) + t.is(callbacks, 0) + while (callbacks < results.succeeded) { + await new Promise((resolve) => setImmediate(resolve)) + } + t.is(callbacks, results.succeeded) + native.tsfnTestReleaseRetainedDelegate() + }) +} diff --git a/bindings/js/package.json b/bindings/js/package.json index c044f491..141f02c0 100644 --- a/bindings/js/package.json +++ b/bindings/js/package.json @@ -74,6 +74,7 @@ "bench": "node --import @oxc-node/core/register ../../benchmarks/js/dynamic/bench.ts", "build": "napi build --no-const-enum --platform --release -o dist && npm run build:entrypoints", "build:debug": "napi --no-const-enum build --platform -o dist && npm run build:entrypoints", + "build:test-hooks": "napi build --no-const-enum --platform --release --features test-hooks -o dist", "build:entrypoints": "node scripts/generate-entrypoints.mjs", "format": "run-p format:prettier format:rs format:toml", "format:prettier": "prettier . -w", @@ -81,6 +82,7 @@ "format:rs": "cargo fmt", "lint": "oxlint", "test": "ava", + "test:tsfn": "npm run build:test-hooks && ava __test__/tsfn.spec.ts", "version": "napi version" }, "devDependencies": { diff --git a/bindings/js/src/com.rs b/bindings/js/src/com.rs index 55116d13..b2ee33ab 100644 --- a/bindings/js/src/com.rs +++ b/bindings/js/src/com.rs @@ -4553,17 +4553,17 @@ impl DynCom { } #[napi] - pub fn to_number(value: &DynWinRTValue) -> i32 { + pub fn to_number(value: &DynWinRTValue) -> napi::Result { value.to_number() } #[napi] - pub fn to_bool(value: &DynWinRTValue) -> bool { + pub fn to_bool(value: &DynWinRTValue) -> napi::Result { value.to_bool() } #[napi] - pub fn to_f64(value: &DynWinRTValue) -> f64 { + pub fn to_f64(value: &DynWinRTValue) -> napi::Result { value.to_f64() } diff --git a/bindings/js/src/lib.rs b/bindings/js/src/lib.rs index 19b82ddb..b5249877 100644 --- a/bindings/js/src/lib.rs +++ b/bindings/js/src/lib.rs @@ -12,7 +12,6 @@ use std::{ use dynwinrt; use napi::bindgen_prelude::{BigInt, Either, PromiseRaw}; -use napi::threadsafe_function::ThreadsafeFunctionCallMode; use napi::Env; use napi_derive::napi; use windows::core::{IUnknown, Interface, HSTRING}; @@ -25,7 +24,10 @@ pub use com::{ DynComSafeArrayBound, DynComType, DynComUnsafe, DynComUnsafeInterface, DynComVariant, }; mod async_promise; +mod managed_tsfn; mod scheduled_start; +#[cfg(feature = "test-hooks")] +mod tsfn_test_hooks; /// Shared MetadataTable — created once, used everywhere. static TABLE: std::sync::LazyLock> = @@ -56,6 +58,119 @@ fn winui_dispatcher_loop_exited() -> bool { WINUI_DISPATCHER_LOOP_ENTERED.with(Cell::get) && !winui_dispatcher_loop_active() } +fn js_u64(value: Either, context: &str) -> napi::Result { + match value { + Either::A(value) => { + let (negative, value, lossless) = value.get_u64(); + if negative || !lossless { + return Err(napi::Error::from_reason(format!( + "{context}: bigint value must fit in an unsigned 64-bit integer", + ))); + } + Ok(value) + } + Either::B(value) => { + if !value.is_finite() + || value.fract() != 0.0 + || !(0.0..=9_007_199_254_740_991.0).contains(&value) + { + return Err(napi::Error::from_reason(format!( + "{context}: number value must be a non-negative safe integer; use bigint for larger values", + ))); + } + Ok(value as u64) + } + } +} + +fn js_i64(value: Either, context: &str) -> napi::Result { + match value { + Either::A(value) => { + let (value, lossless) = value.get_i64(); + if !lossless { + return Err(napi::Error::from_reason(format!( + "{context}: bigint value must fit in a signed 64-bit integer", + ))); + } + Ok(value) + } + Either::B(value) => { + if !value.is_finite() || value.fract() != 0.0 || value.abs() > 9_007_199_254_740_991.0 { + return Err(napi::Error::from_reason(format!( + "{context}: number value must be a safe integer; use bigint for larger values", + ))); + } + Ok(value as i64) + } + } +} + +fn js_i32(value: f64, context: &str) -> napi::Result { + if !value.is_finite() + || value.fract() != 0.0 + || value < f64::from(i32::MIN) + || value > f64::from(i32::MAX) + { + return Err(napi::Error::from_reason(format!( + "{context}: value must be an integer in the i32 range", + ))); + } + Ok(value as i32) +} + +fn js_u32(value: f64, context: &str) -> napi::Result { + if !value.is_finite() || value.fract() != 0.0 || !(0.0..=f64::from(u32::MAX)).contains(&value) { + return Err(napi::Error::from_reason(format!( + "{context}: value must be an integer in the u32 range", + ))); + } + Ok(value as u32) +} + +fn js_safe_i64(value: i64, context: &str) -> napi::Result { + const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991; + if !(-MAX_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(&value) { + return Err(napi::Error::from_reason(format!( + "{context}: value is outside the JavaScript safe-integer range; use the bigint conversion instead", + ))); + } + Ok(value) +} + +fn collect_typed_array( + array: &dynwinrt::ArrayData, + method: &str, + expected: impl Fn(dynwinrt::TypeKind) -> bool, + from_raw: impl Fn(T) -> U, + from_value: impl Fn(dynwinrt::WinRTValue) -> Option, +) -> napi::Result> +where + T: Copy, +{ + let actual = array.element_type.kind(); + if !expected(actual) { + return Err(napi::Error::from_reason(format!( + "{method} cannot read an array with element type {actual:?}", + ))); + } + + if let Some(values) = unsafe { array.try_as_typed_slice::() } { + return Ok(values.iter().copied().map(from_raw).collect()); + } + + (0..array.len()) + .map(|index| { + let value = array.get(index); + let actual = value.get_type_kind(); + from_value(value).ok_or_else(|| { + napi::Error::from_reason(format!( + "{method} found incompatible stored value {actual:?} at index {index}", + )) + }) + }) + .collect() +} + /// Add Windows App SDK to the process package graph without changing the calling thread's apartment. #[napi] pub fn init_winappsdk(major: u32, minor: u32) -> napi::Result<()> { @@ -1050,51 +1165,12 @@ impl DynWinRTValue { } #[napi] pub fn i64(value: Either) -> napi::Result { - let value = match value { - Either::A(value) => { - let (value, lossless) = value.get_i64(); - if !lossless { - return Err(napi::Error::from_reason( - "i64 value must fit in a signed 64-bit integer", - )); - } - value - } - Either::B(value) => { - if !value.is_finite() || value.fract() != 0.0 || value.abs() > 9_007_199_254_740_991.0 { - return Err(napi::Error::from_reason( - "i64 number value must be a safe integer; use bigint for larger values", - )); - } - value as i64 - } - }; + let value = js_i64(value, "i64")?; Ok(DynWinRTValue::new(dynwinrt::WinRTValue::I64(value))) } #[napi] pub fn u64(value: Either) -> napi::Result { - let value = match value { - Either::A(value) => { - let (negative, value, lossless) = value.get_u64(); - if negative || !lossless { - return Err(napi::Error::from_reason( - "u64 value must fit in an unsigned 64-bit integer", - )); - } - value - } - Either::B(value) => { - if !value.is_finite() - || value.fract() != 0.0 - || !(0.0..=9_007_199_254_740_991.0).contains(&value) - { - return Err(napi::Error::from_reason( - "u64 number value must be a non-negative safe integer; use bigint for larger values", - )); - } - value as u64 - } - }; + let value = js_u64(value, "u64")?; Ok(DynWinRTValue::new(dynwinrt::WinRTValue::U64(value))) } #[napi] @@ -1237,18 +1313,36 @@ impl DynWinRTValue { .progress_handler_iid() .ok_or_else(|| napi::Error::from_reason("onProgress: cannot compute progress handler IID"))?; + use napi::bindgen_prelude::ToNapiValue; + use napi::JsValue; + // Progress callbacks must not keep an otherwise idle Node process alive. - let tsfn = callback - .build_threadsafe_function() - .weak::() - .build()?; - let progress_cb: dynwinrt::ProgressCallback = Box::new(move |val: dynwinrt::WinRTValue| { - tsfn.call( - DynWinRTValue::new(val), - ThreadsafeFunctionCallMode::NonBlocking, - ); - }); - let handler = dynwinrt::create_progress_handler(handler_iid, progress_type, progress_cb); + let raw_env = callback.value().env; + let raw_callback = napi::JsValue::raw(&callback); + let tsfn = managed_tsfn::ManagedTsfn::create( + raw_env, + raw_callback, + 1024, + true, + |value: DynWinRTValue, env| { + unsafe { DynWinRTValue::to_napi_value(env, value) }.map(|value| vec![value]) + }, + None, + )?; + let progress_cb: dynwinrt::ProgressResultCallback = + Box::new(move |val: dynwinrt::WinRTValue| { + let status = tsfn.call(DynWinRTValue::new(val)); + if status == napi::Status::Ok { + windows::core::HRESULT(0) + } else { + if status != napi::Status::QueueFull { + eprintln!("[dynwinrt] progress callback queue failed: {status}"); + } + windows::core::HRESULT(0x80004005u32 as i32) + } + }); + let handler = + dynwinrt::create_progress_handler_with_result(handler_iid, progress_type, progress_cb); async_info .set_progress_handler(&handler) @@ -1300,42 +1394,50 @@ impl DynWinRTValue { } #[napi] - pub fn to_number(&self) -> i32 { - match &self.0 { + pub fn to_number(&self) -> napi::Result { + Ok(match &self.0 { dynwinrt::WinRTValue::Bool(b) => { if *b { - 1 + 1.0 } else { - 0 + 0.0 } } - dynwinrt::WinRTValue::I8(i) => *i as i32, - dynwinrt::WinRTValue::U8(i) => *i as i32, - dynwinrt::WinRTValue::I16(i) => *i as i32, - dynwinrt::WinRTValue::U16(i) => *i as i32, - dynwinrt::WinRTValue::I32(i) => *i, - dynwinrt::WinRTValue::U32(i) => *i as i32, - dynwinrt::WinRTValue::HResult(hr) => hr.0, - dynwinrt::WinRTValue::Enum { value, .. } => *value, - _ => panic!("Cannot convert {:?} to number", self.0.get_type_kind()), - } + dynwinrt::WinRTValue::I8(i) => f64::from(*i), + dynwinrt::WinRTValue::U8(i) => f64::from(*i), + dynwinrt::WinRTValue::I16(i) => f64::from(*i), + dynwinrt::WinRTValue::U16(i) => f64::from(*i), + dynwinrt::WinRTValue::I32(i) => f64::from(*i), + dynwinrt::WinRTValue::U32(i) => f64::from(*i), + dynwinrt::WinRTValue::HResult(hr) => f64::from(hr.0), + dynwinrt::WinRTValue::Enum { value, .. } => f64::from(*value), + _ => { + return Err(napi::Error::from_reason(format!( + "Cannot convert {:?} to number", + self.0.get_type_kind(), + ))); + } + }) } #[napi] - pub fn to_bool(&self) -> bool { + pub fn to_bool(&self) -> napi::Result { match &self.0 { - dynwinrt::WinRTValue::Bool(b) => *b, - _ => self.to_number() != 0, + dynwinrt::WinRTValue::Bool(b) => Ok(*b), + _ => self.to_number().map(|value| value != 0.0), } } #[napi] - pub fn to_i64(&self) -> i64 { - match &self.0 { + pub fn to_i64(&self) -> napi::Result { + let value = match &self.0 { dynwinrt::WinRTValue::I64(i) => *i, - dynwinrt::WinRTValue::U64(i) => *i as i64, - _ => self.to_number() as i64, - } + dynwinrt::WinRTValue::U64(i) => i64::try_from(*i).map_err(|_| { + napi::Error::from_reason("Cannot convert u64 value greater than i64::MAX to i64") + })?, + _ => self.to_number().map(|value| value as i64)?, + }; + js_safe_i64(value, "toI64") } #[napi] @@ -1359,11 +1461,11 @@ impl DynWinRTValue { } #[napi] - pub fn to_f64(&self) -> f64 { + pub fn to_f64(&self) -> napi::Result { match &self.0 { - dynwinrt::WinRTValue::F64(f) => *f, - dynwinrt::WinRTValue::F32(f) => *f as f64, - _ => self.to_number() as f64, + dynwinrt::WinRTValue::F64(f) => Ok(*f), + dynwinrt::WinRTValue::F32(f) => Ok(*f as f64), + _ => self.to_number(), } } @@ -1381,10 +1483,12 @@ impl DynWinRTValue { } #[napi] - pub fn as_raw(&self) -> i64 { + pub fn as_raw(&self) -> napi::Result { match &self.0 { - dynwinrt::WinRTValue::Object(o) => o.as_raw() as i64, - _ => panic!("Cannot get raw pointer from non-object"), + dynwinrt::WinRTValue::Object(o) => Ok(o.as_raw() as i64), + _ => Err(napi::Error::from_reason( + "Cannot get raw pointer from non-object", + )), } } @@ -1448,8 +1552,15 @@ impl DynWinRTArray { /// Per-element access (works for all element types). #[napi] - pub fn get(&self, index: u32) -> DynWinRTValue { - DynWinRTValue::new(self.0.get(index as usize)) + pub fn get(&self, index: f64) -> napi::Result { + let index = js_u32(index, "get")? as usize; + if index >= self.0.len() { + return Err(napi::Error::from_reason(format!( + "Array index {index} is out of bounds for length {}", + self.0.len(), + ))); + } + Ok(DynWinRTValue::new(self.0.get(index))) } /// Convert all elements to DynWinRTValue array. @@ -1460,92 +1571,160 @@ impl DynWinRTArray { .collect() } - // -- Blittable fast paths: zero-copy read into typed Vec -- + // -- Typed batch conversions -- #[napi] - pub fn to_i8_vec(&self) -> Vec { - unsafe { - self - .0 - .as_typed_slice::() - .iter() - .map(|&v| v as i32) - .collect() - } + pub fn to_i8_vec(&self) -> napi::Result> { + collect_typed_array( + &self.0, + "toI8Vec", + |kind| kind == dynwinrt::TypeKind::I8, + |value: i8| i32::from(value), + |value| match value { + dynwinrt::WinRTValue::I8(value) => Some(i32::from(value)), + _ => None, + }, + ) } #[napi] - pub fn to_u8_vec(&self) -> Vec { - unsafe { self.0.as_typed_slice::().to_vec() } + pub fn to_u8_vec(&self) -> napi::Result> { + collect_typed_array( + &self.0, + "toU8Vec", + |kind| matches!(kind, dynwinrt::TypeKind::U8 | dynwinrt::TypeKind::Bool), + |value: u8| value, + |value| match value { + dynwinrt::WinRTValue::U8(value) => Some(value), + dynwinrt::WinRTValue::Bool(value) => Some(u8::from(value)), + _ => None, + }, + ) } /// Return the u8 array data as a Node.js Buffer (zero-copy friendly, much /// more memory-efficient than to_u8_vec for large byte arrays). #[napi] - pub fn to_buffer(&self) -> napi::bindgen_prelude::Buffer { - let data = unsafe { self.0.as_typed_slice::().to_vec() }; - data.into() + pub fn to_buffer(&self) -> napi::Result { + self.to_u8_vec().map(Into::into) } #[napi] - pub fn to_i16_vec(&self) -> Vec { - unsafe { - self - .0 - .as_typed_slice::() - .iter() - .map(|&v| v as i32) - .collect() - } + pub fn to_i16_vec(&self) -> napi::Result> { + collect_typed_array( + &self.0, + "toI16Vec", + |kind| kind == dynwinrt::TypeKind::I16, + |value: i16| i32::from(value), + |value| match value { + dynwinrt::WinRTValue::I16(value) => Some(i32::from(value)), + _ => None, + }, + ) } #[napi] - pub fn to_u16_vec(&self) -> Vec { - unsafe { - self - .0 - .as_typed_slice::() - .iter() - .map(|&v| v as u32) - .collect() - } + pub fn to_u16_vec(&self) -> napi::Result> { + collect_typed_array( + &self.0, + "toU16Vec", + |kind| matches!(kind, dynwinrt::TypeKind::U16 | dynwinrt::TypeKind::Char16), + |value: u16| u32::from(value), + |value| match value { + dynwinrt::WinRTValue::U16(value) => Some(u32::from(value)), + _ => None, + }, + ) } #[napi] - pub fn to_i32_vec(&self) -> Vec { - unsafe { self.0.as_typed_slice::().to_vec() } + pub fn to_i32_vec(&self) -> napi::Result> { + collect_typed_array( + &self.0, + "toI32Vec", + |kind| { + matches!( + kind, + dynwinrt::TypeKind::I32 | dynwinrt::TypeKind::Enum(_) | dynwinrt::TypeKind::HResult + ) + }, + |value: i32| value, + |value| match value { + dynwinrt::WinRTValue::I32(value) | dynwinrt::WinRTValue::Enum { value, .. } => Some(value), + dynwinrt::WinRTValue::HResult(value) => Some(value.0), + _ => None, + }, + ) } #[napi] - pub fn to_u32_vec(&self) -> Vec { - unsafe { self.0.as_typed_slice::().to_vec() } + pub fn to_u32_vec(&self) -> napi::Result> { + collect_typed_array( + &self.0, + "toU32Vec", + |kind| kind == dynwinrt::TypeKind::U32, + |value: u32| value, + |value| match value { + dynwinrt::WinRTValue::U32(value) => Some(value), + _ => None, + }, + ) } #[napi] - pub fn to_f32_vec(&self) -> Vec { - unsafe { self.0.as_typed_slice::().to_vec() } + pub fn to_f32_vec(&self) -> napi::Result> { + collect_typed_array( + &self.0, + "toF32Vec", + |kind| kind == dynwinrt::TypeKind::F32, + |value: f32| value, + |value| match value { + dynwinrt::WinRTValue::F32(value) => Some(value), + _ => None, + }, + ) } #[napi] - pub fn to_f64_vec(&self) -> Vec { - unsafe { self.0.as_typed_slice::().to_vec() } + pub fn to_f64_vec(&self) -> napi::Result> { + collect_typed_array( + &self.0, + "toF64Vec", + |kind| kind == dynwinrt::TypeKind::F64, + |value: f64| value, + |value| match value { + dynwinrt::WinRTValue::F64(value) => Some(value), + _ => None, + }, + ) } #[napi] - pub fn to_i64_vec(&self) -> Vec { - unsafe { self.0.as_typed_slice::().to_vec() } + pub fn to_i64_vec(&self) -> napi::Result> { + collect_typed_array( + &self.0, + "toI64Vec", + |kind| kind == dynwinrt::TypeKind::I64, + |value: i64| BigInt::from(value), + |value| match value { + dynwinrt::WinRTValue::I64(value) => Some(BigInt::from(value)), + _ => None, + }, + ) } #[napi] - pub fn to_u64_vec(&self) -> Vec { - unsafe { - self - .0 - .as_typed_slice::() - .iter() - .map(|&v| v as i64) - .collect() - } + pub fn to_u64_vec(&self) -> napi::Result> { + collect_typed_array( + &self.0, + "toU64Vec", + |kind| kind == dynwinrt::TypeKind::U64, + |value: u64| BigInt::from(value), + |value| match value { + dynwinrt::WinRTValue::U64(value) => Some(BigInt::from(value)), + _ => None, + }, + ) } // -- Batch string conversion -- @@ -1640,19 +1819,33 @@ impl DynWinRTArray { } #[napi] - pub fn from_i64_values(values: Vec) -> DynWinRTArray { - let wvals: Vec = - values.into_iter().map(dynwinrt::WinRTValue::I64).collect(); - DynWinRTArray(dynwinrt::ArrayData::from_values(TABLE.i64_type(), &wvals)) + pub fn from_i64_values(values: Vec>) -> napi::Result { + let wvals: Vec = values + .into_iter() + .enumerate() + .map(|(index, value)| { + js_i64(value, &format!("fromI64Values[{index}]")).map(dynwinrt::WinRTValue::I64) + }) + .collect::>()?; + Ok(DynWinRTArray(dynwinrt::ArrayData::from_values( + TABLE.i64_type(), + &wvals, + ))) } #[napi] - pub fn from_u64_values(values: Vec) -> DynWinRTArray { + pub fn from_u64_values(values: Vec>) -> napi::Result { let wvals: Vec = values .into_iter() - .map(|v| dynwinrt::WinRTValue::U64(v as u64)) - .collect(); - DynWinRTArray(dynwinrt::ArrayData::from_values(TABLE.u64_type(), &wvals)) + .enumerate() + .map(|(index, value)| { + js_u64(value, &format!("fromU64Values[{index}]")).map(dynwinrt::WinRTValue::U64) + }) + .collect::>()?; + Ok(DynWinRTArray(dynwinrt::ArrayData::from_values( + TABLE.u64_type(), + &wvals, + ))) } #[napi] @@ -1692,6 +1885,26 @@ impl DynWinRTArray { } } +#[cfg(test)] +mod js_boundary_tests { + use super::*; + + #[test] + fn hresult_arrays_use_the_i32_projection() { + let array = DynWinRTArray(dynwinrt::ArrayData::from_values( + TABLE.hresult(), + &[dynwinrt::WinRTValue::HResult(windows::core::HRESULT( + 0x80004005u32 as i32, + ))], + )); + + assert_eq!( + array.to_i32_vec().expect("HRESULT array conversion"), + [0x80004005u32 as i32], + ); + } +} + // ====================================================================== // Struct binding — typed field access by index // ====================================================================== @@ -1701,167 +1914,306 @@ pub struct DynWinRTStruct(dynwinrt::ValueTypeData); unsafe impl Send for DynWinRTStruct {} unsafe impl Sync for DynWinRTStruct {} +impl DynWinRTStruct { + fn checked_field_index( + &self, + index: f64, + method: &str, + expected: &str, + accepts: impl Fn(dynwinrt::TypeKind) -> bool, + ) -> napi::Result { + let index = js_u32(index, method)? as usize; + let handle = self.0.type_handle(); + if index >= handle.field_count() { + return Err(napi::Error::from_reason(format!( + "{method}: field index {index} is out of bounds for {} fields", + handle.field_count(), + ))); + } + let actual = handle.field_type(index).kind(); + if !accepts(actual) { + return Err(napi::Error::from_reason(format!( + "{method}: field {index} has type {actual:?}, expected {expected}", + ))); + } + Ok(index) + } +} + #[napi] impl DynWinRTStruct { /// Create a zero-initialized struct of the given type. #[napi] - pub fn create(typ: &DynWinRTType) -> DynWinRTStruct { - DynWinRTStruct(typ.0.default_value()) + pub fn create(typ: &DynWinRTType) -> napi::Result { + if !matches!(typ.0.kind(), dynwinrt::TypeKind::Struct(_)) { + return Err(napi::Error::from_reason(format!( + "DynWinRtStruct.create requires a struct type, found {:?}", + typ.0.kind(), + ))); + } + Ok(DynWinRTStruct(typ.0.default_value())) } #[napi] - pub fn get_i8(&self, index: u32) -> i32 { - self.0.get_field::(index as usize) as i32 + pub fn get_i8(&self, index: f64) -> napi::Result { + let index = + self.checked_field_index(index, "getI8", "i8", |kind| kind == dynwinrt::TypeKind::I8)?; + Ok(i32::from(self.0.get_field::(index))) } #[napi] - pub fn set_i8(&mut self, index: u32, value: i32) { - self.0.set_field(index as usize, value as i8); + pub fn set_i8(&mut self, index: f64, value: f64) -> napi::Result<()> { + let index = + self.checked_field_index(index, "setI8", "i8", |kind| kind == dynwinrt::TypeKind::I8)?; + let value = i8::try_from(js_i32(value, "setI8")?) + .map_err(|_| napi::Error::from_reason("setI8: value is outside the i8 range"))?; + self.0.set_field(index, value); + Ok(()) } #[napi] - pub fn get_u8(&self, index: u32) -> u32 { - self.0.get_field::(index as usize) as u32 + pub fn get_u8(&self, index: f64) -> napi::Result { + let index = self.checked_field_index(index, "getU8", "u8 or bool", |kind| { + matches!(kind, dynwinrt::TypeKind::U8 | dynwinrt::TypeKind::Bool) + })?; + Ok(u32::from(self.0.get_field::(index))) } #[napi] - pub fn set_u8(&mut self, index: u32, value: u32) { - self.0.set_field(index as usize, value as u8); + pub fn set_u8(&mut self, index: f64, value: f64) -> napi::Result<()> { + let index = self.checked_field_index(index, "setU8", "u8 or bool", |kind| { + matches!(kind, dynwinrt::TypeKind::U8 | dynwinrt::TypeKind::Bool) + })?; + let value = u8::try_from(js_u32(value, "setU8")?) + .map_err(|_| napi::Error::from_reason("setU8: value is outside the u8 range"))?; + self.0.set_field(index, value); + Ok(()) } #[napi] - pub fn get_i16(&self, index: u32) -> i32 { - self.0.get_field::(index as usize) as i32 + pub fn get_i16(&self, index: f64) -> napi::Result { + let index = self.checked_field_index(index, "getI16", "i16", |kind| { + kind == dynwinrt::TypeKind::I16 + })?; + Ok(i32::from(self.0.get_field::(index))) } #[napi] - pub fn set_i16(&mut self, index: u32, value: i32) { - self.0.set_field(index as usize, value as i16); + pub fn set_i16(&mut self, index: f64, value: f64) -> napi::Result<()> { + let index = self.checked_field_index(index, "setI16", "i16", |kind| { + kind == dynwinrt::TypeKind::I16 + })?; + let value = i16::try_from(js_i32(value, "setI16")?) + .map_err(|_| napi::Error::from_reason("setI16: value is outside the i16 range"))?; + self.0.set_field(index, value); + Ok(()) } #[napi] - pub fn get_u16(&self, index: u32) -> u32 { - self.0.get_field::(index as usize) as u32 + pub fn get_u16(&self, index: f64) -> napi::Result { + let index = self.checked_field_index(index, "getU16", "u16 or char16", |kind| { + matches!(kind, dynwinrt::TypeKind::U16 | dynwinrt::TypeKind::Char16) + })?; + Ok(u32::from(self.0.get_field::(index))) } #[napi] - pub fn set_u16(&mut self, index: u32, value: u32) { - self.0.set_field(index as usize, value as u16); + pub fn set_u16(&mut self, index: f64, value: f64) -> napi::Result<()> { + let index = self.checked_field_index(index, "setU16", "u16 or char16", |kind| { + matches!(kind, dynwinrt::TypeKind::U16 | dynwinrt::TypeKind::Char16) + })?; + let value = u16::try_from(js_u32(value, "setU16")?) + .map_err(|_| napi::Error::from_reason("setU16: value is outside the u16 range"))?; + self.0.set_field(index, value); + Ok(()) } #[napi] - pub fn get_i32(&self, index: u32) -> i32 { - self.0.get_field::(index as usize) + pub fn get_i32(&self, index: f64) -> napi::Result { + let index = self.checked_field_index(index, "getI32", "i32, enum, or HRESULT", |kind| { + matches!( + kind, + dynwinrt::TypeKind::I32 | dynwinrt::TypeKind::Enum(_) | dynwinrt::TypeKind::HResult + ) + })?; + Ok(self.0.get_field::(index)) } #[napi] - pub fn set_i32(&mut self, index: u32, value: i32) { - self.0.set_field(index as usize, value); + pub fn set_i32(&mut self, index: f64, value: f64) -> napi::Result<()> { + let index = self.checked_field_index(index, "setI32", "i32, enum, or HRESULT", |kind| { + matches!( + kind, + dynwinrt::TypeKind::I32 | dynwinrt::TypeKind::Enum(_) | dynwinrt::TypeKind::HResult + ) + })?; + self.0.set_field(index, js_i32(value, "setI32")?); + Ok(()) } #[napi] - pub fn get_u32(&self, index: u32) -> u32 { - self.0.get_field::(index as usize) + pub fn get_u32(&self, index: f64) -> napi::Result { + let index = self.checked_field_index(index, "getU32", "u32", |kind| { + kind == dynwinrt::TypeKind::U32 + })?; + Ok(self.0.get_field::(index)) } #[napi] - pub fn set_u32(&mut self, index: u32, value: u32) { - self.0.set_field(index as usize, value); + pub fn set_u32(&mut self, index: f64, value: f64) -> napi::Result<()> { + let index = self.checked_field_index(index, "setU32", "u32", |kind| { + kind == dynwinrt::TypeKind::U32 + })?; + self.0.set_field(index, js_u32(value, "setU32")?); + Ok(()) } #[napi] - pub fn get_f32(&self, index: u32) -> f64 { - self.0.get_field::(index as usize) as f64 + pub fn get_f32(&self, index: f64) -> napi::Result { + let index = self.checked_field_index(index, "getF32", "f32", |kind| { + kind == dynwinrt::TypeKind::F32 + })?; + Ok(f64::from(self.0.get_field::(index))) } #[napi] - pub fn set_f32(&mut self, index: u32, value: f64) { - self.0.set_field(index as usize, value as f32); + pub fn set_f32(&mut self, index: f64, value: f64) -> napi::Result<()> { + let index = self.checked_field_index(index, "setF32", "f32", |kind| { + kind == dynwinrt::TypeKind::F32 + })?; + let converted = value as f32; + if value.is_finite() && !converted.is_finite() { + return Err(napi::Error::from_reason( + "setF32: finite value is outside the f32 range", + )); + } + self.0.set_field(index, converted); + Ok(()) } #[napi] - pub fn get_f64(&self, index: u32) -> f64 { - self.0.get_field::(index as usize) + pub fn get_f64(&self, index: f64) -> napi::Result { + let index = self.checked_field_index(index, "getF64", "f64", |kind| { + kind == dynwinrt::TypeKind::F64 + })?; + Ok(self.0.get_field::(index)) } #[napi] - pub fn set_f64(&mut self, index: u32, value: f64) { - self.0.set_field(index as usize, value); + pub fn set_f64(&mut self, index: f64, value: f64) -> napi::Result<()> { + let index = self.checked_field_index(index, "setF64", "f64", |kind| { + kind == dynwinrt::TypeKind::F64 + })?; + self.0.set_field(index, value); + Ok(()) } #[napi] - pub fn get_i64(&self, index: u32) -> BigInt { - BigInt::from(self.0.get_field::(index as usize)) + pub fn get_i64(&self, index: f64) -> napi::Result { + let index = self.checked_field_index(index, "getI64", "i64", |kind| { + kind == dynwinrt::TypeKind::I64 + })?; + Ok(BigInt::from(self.0.get_field::(index))) } #[napi] - pub fn set_i64(&mut self, index: u32, value: BigInt) { - let (n, _lossless) = value.get_i64(); - self.0.set_field(index as usize, n); + pub fn set_i64(&mut self, index: f64, value: BigInt) -> napi::Result<()> { + let index = self.checked_field_index(index, "setI64", "i64", |kind| { + kind == dynwinrt::TypeKind::I64 + })?; + let (n, lossless) = value.get_i64(); + if !lossless { + return Err(napi::Error::from_reason( + "setI64: bigint value must fit in a signed 64-bit integer", + )); + } + self.0.set_field(index, n); + Ok(()) } #[napi] - pub fn get_u64(&self, index: u32) -> BigInt { - BigInt::from(self.0.get_field::(index as usize)) + pub fn get_u64(&self, index: f64) -> napi::Result { + let index = self.checked_field_index(index, "getU64", "u64", |kind| { + kind == dynwinrt::TypeKind::U64 + })?; + Ok(BigInt::from(self.0.get_field::(index))) } #[napi] - pub fn set_u64(&mut self, index: u32, value: BigInt) { - let (_sign, n, _lossless) = value.get_u64(); - self.0.set_field(index as usize, n); + pub fn set_u64(&mut self, index: f64, value: Either) -> napi::Result<()> { + let index = self.checked_field_index(index, "setU64", "u64", |kind| { + kind == dynwinrt::TypeKind::U64 + })?; + let n = js_u64(value, "setU64")?; + self.0.set_field(index, n); + Ok(()) } // -- Non-blittable field access -- #[napi] - pub fn get_hstring(&self, index: u32) -> String { - let inner = self.0.get_field_struct(index as usize); - // The field is an HSTRING (pointer-sized). Read it as a WinRTValue and convert. - // get_field_struct handles the duplicate/clone of the HSTRING. - // We need to read the raw HSTRING pointer from the inner ValueTypeData. - let hstr: HSTRING = unsafe { - let raw = *(inner.as_ptr() as *const *mut std::ffi::c_void); - if raw.is_null() { - HSTRING::new() - } else { - // Clone so we don't steal the reference from inner (which will Drop) - let hstr_ref: &HSTRING = &*((&raw) as *const *mut std::ffi::c_void as *const HSTRING); - hstr_ref.clone() - } - }; - hstr.to_string() + pub fn get_hstring(&self, index: f64) -> napi::Result { + let index = self.checked_field_index(index, "getHstring", "HSTRING", |kind| { + kind == dynwinrt::TypeKind::HString + })?; + self + .0 + .get_field_hstring(index) + .map(|value| value.to_string()) + .map_err(|error| napi::Error::from_reason(error.message())) } #[napi] - pub fn set_hstring(&mut self, index: u32, value: String) { - let hstr = HSTRING::from(&value); - let field_handle = self.0.type_handle().field_type(index as usize); - let mut field_val = field_handle.default_value(); - unsafe { - let raw: *mut std::ffi::c_void = std::mem::transmute(hstr); - (field_val.as_mut_ptr() as *mut *mut std::ffi::c_void).write(raw); - } - // set_field_struct duplicates non-blittable fields, so field_val's HSTRING - // will be cloned into parent. Let field_val drop normally to release the original. - self.0.set_field_struct(index as usize, &field_val); + pub fn set_hstring(&mut self, index: f64, value: String) -> napi::Result<()> { + let index = self.checked_field_index(index, "setHstring", "HSTRING", |kind| { + kind == dynwinrt::TypeKind::HString + })?; + self + .0 + .set_field_hstring(index, HSTRING::from(value)) + .map_err(|error| napi::Error::from_reason(error.message())) } #[napi] - pub fn get_guid(&self, index: u32) -> WinGUID { + pub fn get_guid(&self, index: f64) -> napi::Result { + let index = self.checked_field_index(index, "getGuid", "GUID", |kind| { + kind == dynwinrt::TypeKind::Guid + })?; let guid = self.0.get_field::(index as usize); - WinGUID(guid) + Ok(WinGUID(guid)) } #[napi] - pub fn set_guid(&mut self, index: u32, value: &WinGUID) { - self.0.set_field(index as usize, value.0); + pub fn set_guid(&mut self, index: f64, value: &WinGUID) -> napi::Result<()> { + let index = self.checked_field_index(index, "setGuid", "GUID", |kind| { + kind == dynwinrt::TypeKind::Guid + })?; + self.0.set_field(index, value.0); + Ok(()) } #[napi] - pub fn get_struct(&self, index: u32) -> DynWinRTStruct { - DynWinRTStruct(self.0.get_field_struct(index as usize)) + pub fn get_struct(&self, index: f64) -> napi::Result { + let index = self.checked_field_index(index, "getStruct", "struct", |kind| { + matches!(kind, dynwinrt::TypeKind::Struct(_)) + })?; + Ok(DynWinRTStruct(self.0.get_field_struct(index))) } #[napi] - pub fn set_struct(&mut self, index: u32, value: &DynWinRTStruct) { - self.0.set_field_struct(index as usize, &value.0); + pub fn set_struct(&mut self, index: f64, value: &DynWinRTStruct) -> napi::Result<()> { + let index = self.checked_field_index(index, "setStruct", "struct", |kind| { + matches!(kind, dynwinrt::TypeKind::Struct(_)) + })?; + let expected = self.0.type_handle().field_type(index).kind(); + let actual = value.0.type_handle().kind(); + if expected != actual { + return Err(napi::Error::from_reason(format!( + "setStruct: field {index} requires {expected:?}, found {actual:?}", + ))); + } + self.0.set_field_struct(index, &value.0); + Ok(()) } #[napi] - pub fn get_object(&self, index: u32) -> napi::Result { + pub fn get_object(&self, index: f64) -> napi::Result { + let index = self.checked_field_index(index, "getObject", "WinRT object", |kind| { + kind.is_com_pointer() + })?; match self .0 - .get_field_object(index as usize) + .get_field_object(index) .map_err(|error| napi::Error::from_reason(error.message()))? { Some(object) => Ok(DynWinRTValue::new(dynwinrt::WinRTValue::Object(object))), @@ -1870,15 +2222,18 @@ impl DynWinRTStruct { } #[napi] - pub fn set_object(&mut self, index: u32, value: &DynWinRTValue) -> napi::Result<()> { + pub fn set_object(&mut self, index: f64, value: &DynWinRTValue) -> napi::Result<()> { + let index = self.checked_field_index(index, "setObject", "WinRT object", |kind| { + kind.is_com_pointer() + })?; match &value.0 { dynwinrt::WinRTValue::Object(obj) => self .0 - .set_field_object(index as usize, Some(obj)) + .set_field_object(index, Some(obj)) .map_err(|error| napi::Error::from_reason(error.message())), dynwinrt::WinRTValue::Null => self .0 - .set_field_object(index as usize, None) + .set_field_object(index, None) .map_err(|error| napi::Error::from_reason(error.message())), _ => Err(napi::Error::from_reason( "setObject requires a WinRT object or null value", @@ -2097,6 +2452,111 @@ impl RustStaticBench { // DynWinRtDelegate — dynamic WinRT delegate (callback) binding // ====================================================================== +struct DirectDelegateCallback { + env: napi::sys::napi_env, + callback_ref: napi::sys::napi_ref, + async_context: napi::sys::napi_async_context, + lifecycle: Arc, +} + +unsafe impl Send for DirectDelegateCallback {} +unsafe impl Sync for DirectDelegateCallback {} + +fn napi_status(name: &str, status: napi::sys::napi_status) -> napi::Result<()> { + if status == napi::sys::Status::napi_ok { + Ok(()) + } else { + Err(napi::Error::from_reason(format!( + "{name} failed with status {}", + napi::Status::from(status), + ))) + } +} + +fn create_direct_delegate_resources( + env: napi::sys::napi_env, + callback: napi::sys::napi_value, +) -> napi::Result<( + napi::sys::napi_ref, + napi::sys::napi_async_context, + Box, +)> { + let mut callback_ref = std::ptr::null_mut(); + napi_status("napi_create_reference(callback)", unsafe { + napi::sys::napi_create_reference(env, callback, 1, &mut callback_ref) + })?; + + let result = (|| { + let mut resource = std::ptr::null_mut(); + napi_status("napi_create_object(delegate resource)", unsafe { + napi::sys::napi_create_object(env, &mut resource) + })?; + + let mut resource_ref = std::ptr::null_mut(); + napi_status("napi_create_reference(delegate resource)", unsafe { + napi::sys::napi_create_reference(env, resource, 1, &mut resource_ref) + })?; + + let async_context = (|| { + let mut resource_name = std::ptr::null_mut(); + let name = b"dynwinrt.delegate"; + napi_status("napi_create_string_utf8(delegate resource)", unsafe { + napi::sys::napi_create_string_utf8( + env, + name.as_ptr().cast(), + name.len() as isize, + &mut resource_name, + ) + })?; + let mut async_context = std::ptr::null_mut(); + napi_status("napi_async_init(delegate)", unsafe { + napi::sys::napi_async_init(env, resource, resource_name, &mut async_context) + })?; + Ok::<_, napi::Error>(async_context) + })(); + + let async_context = match async_context { + Ok(async_context) => async_context, + Err(error) => { + unsafe { + napi::sys::napi_delete_reference(env, resource_ref); + } + return Err(error); + } + }; + + let finalizer: Box = Box::new(move |env| { + if env.is_null() { + return; + } + let async_status = unsafe { napi::sys::napi_async_destroy(env, async_context) }; + if async_status != napi::sys::Status::napi_ok { + eprintln!( + "[dynwinrt] delegate async context cleanup failed: {}", + napi::Status::from(async_status) + ); + } + for reference in [callback_ref, resource_ref] { + let status = unsafe { napi::sys::napi_delete_reference(env, reference) }; + if status != napi::sys::Status::napi_ok { + eprintln!( + "[dynwinrt] delegate reference cleanup failed: {}", + napi::Status::from(status) + ); + } + } + }); + Ok((callback_ref, async_context, finalizer)) + })(); + + if result.is_err() { + unsafe { + napi::sys::napi_delete_reference(env, callback_ref); + } + } + result +} + #[napi] pub struct DynWinRtDelegate(dynwinrt::WinRTValue); @@ -2124,33 +2584,34 @@ impl DynWinRtDelegate { // message pump: in that state libuv is starved and the TSFN uv_async_send // path never wakes up. Any other thread falls back to the TSFN. // - // NOTE: `GetCurrentThreadId` returns a Windows DWORD that the OS is free - // to recycle once a thread exits. We assume the register thread outlives - // every delegate invocation — this holds for the common case (delegate is - // dropped when the subscription is released, and both are typically owned - // by the JS thread that registered them). If a Node worker exits while - // its delegate is still reachable from another thread, a recycled TID - // could steer a cross-thread invocation into the same-thread branch and - // touch a stale `napi_env`. Fixing that would require a per-thread epoch - // or TLS handshake; not needed for current use cases. let register_tid = unsafe { GetCurrentThreadId() }; - - // Raw env is needed to make direct N-API calls from the delegate closure. - // It's only ever dereferenced on `register_tid`, so we wrap it in a Send+Sync - // newtype to satisfy the DelegateCallback trait bounds. - struct SendableEnv(napi::sys::napi_env); - unsafe impl Send for SendableEnv {} - unsafe impl Sync for SendableEnv {} - let raw_env_wrap = Arc::new(SendableEnv(callback.value().env)); - - let fn_ref = Arc::new(callback.create_ref()?); - let tsfn = callback.build_threadsafe_function().build()?; + let raw_env = callback.value().env; + let raw_callback = napi::JsValue::raw(&callback); + let (callback_ref, async_context, finalizer) = + create_direct_delegate_resources(raw_env, raw_callback)?; + let tsfn = managed_tsfn::ManagedTsfn::create( + raw_env, + raw_callback, + 1024, + false, + |values: Vec, env| { + values + .into_iter() + .map(|value| unsafe { DynWinRTValue::to_napi_value(env, value) }) + .collect() + }, + Some(finalizer), + )?; + let lifecycle = tsfn.lifecycle(); + let direct = Arc::new(DirectDelegateCallback { + env: raw_env, + callback_ref, + async_context, + lifecycle, + }); let type_handles: Vec = param_types.iter().map(|t| t.0.clone()).collect(); - let raw_env_cb = raw_env_wrap.clone(); - let fn_ref_cb = fn_ref.clone(); - let delegate_callback: dynwinrt::delegate::DelegateCallback = Box::new(move |args: &[dynwinrt::WinRTValue]| { // Well-known HRESULTs used below to signal failure to the WinRT event @@ -2164,6 +2625,9 @@ impl DynWinRtDelegate { args.iter().map(|a| DynWinRTValue::new(a.clone())).collect(); if current_tid == register_tid { + if direct.lifecycle.is_closing() { + return E_FAIL; + } // Same-thread synchronous direct invocation. Bypass the TSFN because // libuv may be blocked (e.g. DispatcherQueue.runEventLoop), so // uv_async_send would queue the callback but never fire it. @@ -2172,7 +2636,7 @@ impl DynWinRtDelegate { // ultimately called by an `extern "system"` COM stub, and letting a // Rust panic unwind through the FFI boundary is UB. On panic we // convert to E_UNEXPECTED so the WinRT caller sees a clean failure. - let raw_env = raw_env_cb.0; + let raw_env = direct.env; let unwind_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe( || -> windows::core::HRESULT { unsafe { @@ -2186,10 +2650,11 @@ impl DynWinRtDelegate { eprintln!("[dynwinrt] delegate: napi_open_handle_scope failed (env teardown?)"); return E_FAIL; } - let scoped_env = napi::Env::from_raw(raw_env); let call_result = (|| -> napi::Result<()> { - let fn_scope = fn_ref_cb.borrow_back(&scoped_env)?; - let fn_val = napi::JsValue::raw(&fn_scope); + let mut fn_val = std::ptr::null_mut(); + napi_status("napi_get_reference_value(delegate callback)", { + napi::sys::napi_get_reference_value(raw_env, direct.callback_ref, &mut fn_val) + })?; // Spread each DynWinRTValue as its own napi_value so the JS // callback receives them as positional args. The blanket // `Vec::into_vec` impl wraps the whole vec as a single JS @@ -2200,11 +2665,16 @@ impl DynWinRtDelegate { argv.push(raw); } let mut receiver: napi::sys::napi_value = std::ptr::null_mut(); - napi::sys::napi_get_global(raw_env, &mut receiver); + let status = napi::sys::napi_get_global(raw_env, &mut receiver); + if status != napi::sys::Status::napi_ok { + return Err(napi::Error::from_reason(format!( + "napi_get_global failed with status {status}", + ))); + } let mut result: napi::sys::napi_value = std::ptr::null_mut(); let status = napi::sys::napi_make_callback( raw_env, - std::ptr::null_mut(), + direct.async_context, receiver, fn_val, argv.len(), @@ -2217,17 +2687,42 @@ impl DynWinRtDelegate { // propagate a JS throw back through WinRT, so we route it through // napi_fatal_exception (same policy tsfn uses). let mut is_pending: bool = false; - napi::sys::napi_is_exception_pending(raw_env, &mut is_pending); + let pending_status = + napi::sys::napi_is_exception_pending(raw_env, &mut is_pending); + if pending_status != napi::sys::Status::napi_ok { + return Err(napi::Error::from_reason(format!( + "napi_is_exception_pending failed with status {pending_status}", + ))); + } if is_pending { let mut err: napi::sys::napi_value = std::ptr::null_mut(); - napi::sys::napi_get_and_clear_last_exception(raw_env, &mut err); - napi::sys::napi_fatal_exception(raw_env, err); + let clear_status = + napi::sys::napi_get_and_clear_last_exception(raw_env, &mut err); + if clear_status != napi::sys::Status::napi_ok { + return Err(napi::Error::from_reason(format!( + "napi_get_and_clear_last_exception failed with status {clear_status}", + ))); + } + let fatal_status = napi::sys::napi_fatal_exception(raw_env, err); + if fatal_status != napi::sys::Status::napi_ok { + return Err(napi::Error::from_reason(format!( + "napi_fatal_exception failed with status {fatal_status}", + ))); + } } - return Err(napi::Error::from_reason("napi_make_callback failed")); + return Err(napi::Error::from_reason(format!( + "napi_make_callback failed with status {status}", + ))); } Ok(()) })(); - napi::sys::napi_close_handle_scope(raw_env, scope); + let close_status = napi::sys::napi_close_handle_scope(raw_env, scope); + if close_status != napi::sys::Status::napi_ok { + eprintln!( + "[dynwinrt] delegate: napi_close_handle_scope failed with status {close_status}" + ); + return E_FAIL; + } // Log and report any non-exception error to WinRT. Without this, // failures like invalid handles or marshaler errors would be // silently dropped and the delegate would appear to have run. @@ -2251,8 +2746,15 @@ impl DynWinRtDelegate { // Cross-thread fallback: schedule via the TSFN. This requires libuv to // be pumping on the JS thread, which is fine for classic Node.js work // but not for a JS thread stuck inside a foreign message pump. - tsfn.call(js_args, ThreadsafeFunctionCallMode::NonBlocking); - windows::core::HRESULT(0) + let status = tsfn.call(js_args); + if status == napi::Status::Ok { + windows::core::HRESULT(0) + } else { + if status != napi::Status::QueueFull { + eprintln!("[dynwinrt] delegate callback queue failed: {status}"); + } + E_FAIL + } }); let value = dynwinrt::delegate::create_delegate_value(iid.0, type_handles, delegate_callback); diff --git a/bindings/js/src/managed_tsfn.rs b/bindings/js/src/managed_tsfn.rs new file mode 100644 index 00000000..fc0a2cba --- /dev/null +++ b/bindings/js/src/managed_tsfn.rs @@ -0,0 +1,479 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use std::{ + collections::HashMap, + ffi::c_void, + ptr, + sync::{ + atomic::{AtomicPtr, AtomicU64, Ordering}, + Arc, LazyLock, Mutex, RwLock, Weak, + }, +}; + +use napi::{sys, Env, JsError, Status}; + +static NEXT_TSFN_ID: AtomicU64 = AtomicU64::new(1); +static ENV_LIFECYCLES: LazyLock>>> = + LazyLock::new(|| Mutex::new(HashMap::new())); +#[cfg(feature = "test-hooks")] +static TEST_PAUSE_CALL: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); +#[cfg(feature = "test-hooks")] +static TEST_CALL_PAUSED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); +#[cfg(feature = "test-hooks")] +static TEST_CLEANUP_WAITING: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); +#[cfg(feature = "test-hooks")] +static TEST_CLEANUP_ACQUIRED: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); + +struct RegisteredHandle { + id: u64, + native: Arc, +} + +pub(crate) struct TsfnLifecycle { + open: RwLock, + handles: Mutex>, +} + +impl TsfnLifecycle { + fn get_or_create(env: Env) -> napi::Result> { + let env_key = env.raw() as usize; + let mut lifecycles = ENV_LIFECYCLES + .lock() + .map_err(|_| napi::Error::from_reason("TSFN environment registry is poisoned"))?; + if let Some(lifecycle) = lifecycles.get(&env_key).and_then(Weak::upgrade) { + return Ok(lifecycle); + } + + let lifecycle = Arc::new(Self { + open: RwLock::new(true), + handles: Mutex::new(Vec::new()), + }); + let cleanup_lifecycle = lifecycle.clone(); + let _hook = + env.add_env_cleanup_hook((env_key, cleanup_lifecycle), |(env_key, lifecycle)| { + lifecycle.close_and_abort(); + ENV_LIFECYCLES + .lock() + .unwrap_or_else(|error| error.into_inner()) + .remove(&env_key); + })?; + lifecycles.insert(env_key, Arc::downgrade(&lifecycle)); + Ok(lifecycle) + } + + pub(crate) fn is_closing(&self) -> bool { + !*self.open.read().unwrap_or_else(|error| error.into_inner()) + } + + fn with_open(&self, closed: R, callback: impl FnOnce() -> R) -> R { + let open = self.open.read().unwrap_or_else(|error| error.into_inner()); + if *open { + callback() + } else { + closed + } + } + + fn register(&self, id: u64, native: Arc) { + let mut handles = self + .handles + .lock() + .unwrap_or_else(|error| error.into_inner()); + handles.push(RegisteredHandle { id, native }); + } + + fn unregister(&self, id: u64) -> Option> { + let mut handles = self + .handles + .lock() + .unwrap_or_else(|error| error.into_inner()); + handles + .iter() + .position(|handle| handle.id == id) + .map(|index| handles.swap_remove(index).native) + } + + fn close_and_abort(&self) { + #[cfg(feature = "test-hooks")] + TEST_CLEANUP_WAITING.store(true, Ordering::SeqCst); + let mut open = self.open.write().unwrap_or_else(|error| error.into_inner()); + #[cfg(feature = "test-hooks")] + TEST_CLEANUP_ACQUIRED.store(true, Ordering::SeqCst); + if !*open { + return; + } + *open = false; + drop(open); + + let handles = self + .handles + .lock() + .unwrap_or_else(|error| error.into_inner()) + .drain(..) + .map(|handle| handle.native) + .collect::>(); + for native in handles { + native.release(sys::ThreadsafeFunctionReleaseMode::abort); + } + } +} + +type Mapper = dyn Fn(T, sys::napi_env) -> napi::Result> + Send + Sync; +pub(crate) type TsfnFinalizer = dyn FnOnce(sys::napi_env); + +struct TsfnContext { + mapper: Box>, + finalizer: Option>, + fallback_env: sys::napi_env, +} + +impl TsfnContext { + fn finalize(&mut self, env: sys::napi_env) { + if let Some(finalizer) = self.finalizer.take() { + finalizer(env); + } + } +} + +impl Drop for TsfnContext { + fn drop(&mut self) { + if let Some(finalizer) = self.finalizer.take() { + finalizer(self.fallback_env); + } + } +} + +struct NativeTsfn { + raw: AtomicPtr, +} + +unsafe impl Send for NativeTsfn {} +unsafe impl Sync for NativeTsfn {} + +impl NativeTsfn { + fn release(&self, mode: sys::napi_threadsafe_function_release_mode) { + let raw = self.raw.swap(ptr::null_mut(), Ordering::AcqRel); + if raw.is_null() { + return; + } + let status = unsafe { sys::napi_release_threadsafe_function(raw, mode) }; + if status != sys::Status::napi_ok && status != sys::Status::napi_closing { + eprintln!( + "[dynwinrt] managed TSFN release failed: {}", + Status::from(status) + ); + } + } +} + +struct TsfnHandle { + id: u64, + native: Arc, + lifecycle: Arc, +} + +unsafe impl Send for TsfnHandle {} +unsafe impl Sync for TsfnHandle {} + +impl Drop for TsfnHandle { + fn drop(&mut self) { + self.lifecycle.with_open((), || { + if let Some(native) = self.lifecycle.unregister(self.id) { + debug_assert!(Arc::ptr_eq(&native, &self.native)); + native.release(sys::ThreadsafeFunctionReleaseMode::release); + } + }); + } +} + +pub(crate) struct ManagedTsfn { + handle: Arc, + _payload: std::marker::PhantomData, +} + +unsafe impl Send for ManagedTsfn {} +unsafe impl Sync for ManagedTsfn {} + +impl Clone for ManagedTsfn { + fn clone(&self) -> Self { + Self { + handle: self.handle.clone(), + _payload: std::marker::PhantomData, + } + } +} + +impl ManagedTsfn { + pub(crate) fn create( + env: sys::napi_env, + callback: sys::napi_value, + max_queue_size: usize, + weak: bool, + mapper: impl Fn(T, sys::napi_env) -> napi::Result> + Send + Sync + 'static, + finalizer: Option>, + ) -> napi::Result { + let lifecycle = TsfnLifecycle::get_or_create(Env::from_raw(env))?; + let open = lifecycle + .open + .read() + .unwrap_or_else(|error| error.into_inner()); + if !*open { + return Err(napi::Error::from_reason( + "Cannot create a TSFN while the Node environment is closing", + )); + } + + let context = Box::new(TsfnContext { + mapper: Box::new(mapper), + finalizer, + fallback_env: env, + }); + let context_ptr = Box::into_raw(context); + let native = Arc::new(NativeTsfn { + raw: AtomicPtr::new(ptr::null_mut()), + }); + let handle = Arc::new(TsfnHandle { + id: NEXT_TSFN_ID.fetch_add(1, Ordering::Relaxed), + native: native.clone(), + lifecycle: lifecycle.clone(), + }); + let finalize_native = Weak::into_raw(Arc::downgrade(&native)); + + let mut resource_name = ptr::null_mut(); + let name = b"dynwinrt.managedTsfn"; + let name_status = unsafe { + sys::napi_create_string_utf8( + env, + name.as_ptr().cast(), + name.len() as isize, + &mut resource_name, + ) + }; + if name_status != sys::Status::napi_ok { + unsafe { + drop(Box::from_raw(context_ptr)); + drop(Weak::from_raw(finalize_native)); + } + return Err(napi::Error::from_reason(format!( + "Failed to create managed TSFN resource name: {}", + Status::from(name_status), + ))); + } + + let mut raw = ptr::null_mut(); + let create_status = unsafe { + sys::napi_create_threadsafe_function( + env, + callback, + ptr::null_mut(), + resource_name, + max_queue_size, + 1, + finalize_native.cast_mut().cast(), + Some(finalize_tsfn::), + context_ptr.cast(), + Some(call_js::), + &mut raw, + ) + }; + if create_status != sys::Status::napi_ok { + unsafe { + drop(Box::from_raw(context_ptr)); + drop(Weak::from_raw(finalize_native)); + } + return Err(napi::Error::from_reason(format!( + "Failed to create managed TSFN: {}", + Status::from(create_status), + ))); + } + native.raw.store(raw, Ordering::Release); + + if weak { + let status = unsafe { sys::napi_unref_threadsafe_function(env, raw) }; + if status != sys::Status::napi_ok { + native.release(sys::ThreadsafeFunctionReleaseMode::abort); + return Err(napi::Error::from_reason(format!( + "Failed to unref managed TSFN: {}", + Status::from(status), + ))); + } + } + + handle.lifecycle.register(handle.id, native); + drop(open); + Ok(Self { + handle, + _payload: std::marker::PhantomData, + }) + } + + pub(crate) fn lifecycle(&self) -> Arc { + self.handle.lifecycle.clone() + } + + pub(crate) fn call(&self, value: T) -> Status { + self.handle.lifecycle.with_open(Status::Closing, || { + #[cfg(feature = "test-hooks")] + { + if TEST_PAUSE_CALL.load(Ordering::SeqCst) { + TEST_CALL_PAUSED.store(true, Ordering::SeqCst); + while TEST_PAUSE_CALL.load(Ordering::SeqCst) { + std::thread::yield_now(); + } + } + } + let raw = self.handle.native.raw.load(Ordering::Acquire); + if raw.is_null() { + return Status::Closing; + } + + let payload = Box::into_raw(Box::new(value)); + let status = unsafe { + sys::napi_call_threadsafe_function( + raw, + payload.cast(), + sys::ThreadsafeFunctionCallMode::nonblocking, + ) + }; + if status != sys::Status::napi_ok { + unsafe { drop(Box::from_raw(payload)) }; + if status == sys::Status::napi_closing { + self + .handle + .native + .raw + .store(ptr::null_mut(), Ordering::Release); + } + } + Status::from(status) + }) + } +} + +#[cfg(feature = "test-hooks")] +pub(crate) fn test_arm_call_pause() { + TEST_CALL_PAUSED.store(false, Ordering::SeqCst); + TEST_CLEANUP_WAITING.store(false, Ordering::SeqCst); + TEST_CLEANUP_ACQUIRED.store(false, Ordering::SeqCst); + TEST_PAUSE_CALL.store(true, Ordering::SeqCst); +} + +#[cfg(feature = "test-hooks")] +pub(crate) fn test_call_paused() -> bool { + TEST_CALL_PAUSED.load(Ordering::SeqCst) +} + +#[cfg(feature = "test-hooks")] +pub(crate) fn test_cleanup_waiting() -> bool { + TEST_CLEANUP_WAITING.load(Ordering::SeqCst) +} + +#[cfg(feature = "test-hooks")] +pub(crate) fn test_cleanup_acquired() -> bool { + TEST_CLEANUP_ACQUIRED.load(Ordering::SeqCst) +} + +#[cfg(feature = "test-hooks")] +pub(crate) fn test_release_call_pause() { + TEST_PAUSE_CALL.store(false, Ordering::SeqCst); +} + +#[cfg(feature = "test-hooks")] +pub(crate) fn test_registered_handle_count(env: sys::napi_env) -> usize { + ENV_LIFECYCLES + .lock() + .unwrap_or_else(|error| error.into_inner()) + .get(&(env as usize)) + .and_then(Weak::upgrade) + .map(|lifecycle| { + lifecycle + .handles + .lock() + .unwrap_or_else(|error| error.into_inner()) + .len() + }) + .unwrap_or(0) +} + +unsafe extern "C" fn finalize_tsfn( + env: sys::napi_env, + finalize_data: *mut c_void, + finalize_hint: *mut c_void, +) { + let native = unsafe { Weak::::from_raw(finalize_data.cast()) }; + if let Some(native) = native.upgrade() { + native.raw.store(ptr::null_mut(), Ordering::Release); + } + let mut context = unsafe { Box::>::from_raw(finalize_hint.cast()) }; + context.finalize(env); +} + +unsafe extern "C" fn call_js( + env: sys::napi_env, + callback: sys::napi_value, + context: *mut c_void, + data: *mut c_void, +) { + if data.is_null() { + return; + } + let value = unsafe { *Box::::from_raw(data.cast()) }; + if env.is_null() || callback.is_null() { + return; + } + + let context = unsafe { &*context.cast::>() }; + let args = match (context.mapper)(value, env) { + Ok(args) => args, + Err(error) => { + let error = unsafe { JsError::from(error).into_value(env) }; + unsafe { + sys::napi_fatal_exception(env, error); + } + return; + } + }; + + let mut receiver = ptr::null_mut(); + let receiver_status = unsafe { sys::napi_get_undefined(env, &mut receiver) }; + if receiver_status != sys::Status::napi_ok { + report_callback_status(env, receiver_status); + return; + } + + let mut result = ptr::null_mut(); + let status = unsafe { + sys::napi_call_function( + env, + receiver, + callback, + args.len(), + args.as_ptr(), + &mut result, + ) + }; + report_callback_status(env, status); +} + +fn report_callback_status(env: sys::napi_env, status: sys::napi_status) { + if status == sys::Status::napi_ok { + return; + } + if status == sys::Status::napi_pending_exception { + let mut error = ptr::null_mut(); + let clear = unsafe { sys::napi_get_and_clear_last_exception(env, &mut error) }; + if clear == sys::Status::napi_ok { + unsafe { + sys::napi_fatal_exception(env, error); + } + } + return; + } + eprintln!( + "[dynwinrt] managed TSFN callback failed: {}", + Status::from(status) + ); +} diff --git a/bindings/js/src/tsfn_test_hooks.rs b/bindings/js/src/tsfn_test_hooks.rs new file mode 100644 index 00000000..68caf9f3 --- /dev/null +++ b/bindings/js/src/tsfn_test_hooks.rs @@ -0,0 +1,367 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use std::{ + ffi::c_void, + sync::{ + atomic::{AtomicUsize, Ordering}, + Mutex, + }, + thread, + time::{Duration, Instant}, +}; + +use napi::{ + bindgen_prelude::{Function, ToNapiValue}, + Env, JsValue, Status, +}; +use napi_derive::napi; +use windows::core::{IUnknown, Interface}; + +use crate::{ + managed_tsfn::{self, ManagedTsfn}, + DynWinRtDelegate, +}; + +static PRODUCED: AtomicUsize = AtomicUsize::new(0); +static DROPPED: AtomicUsize = AtomicUsize::new(0); +static ACCEPTED: AtomicUsize = AtomicUsize::new(0); +static QUEUE_FULL: AtomicUsize = AtomicUsize::new(0); +static CLOSING: AtomicUsize = AtomicUsize::new(0); +static OTHER_FAILURE: AtomicUsize = AtomicUsize::new(0); + +static HELD_STRONG: Mutex>> = Mutex::new(None); +static HELD_WEAK: Mutex>> = Mutex::new(None); +static RETAINED_DELEGATE: AtomicUsize = AtomicUsize::new(0); +static DELEGATE_STRESS_DONE: AtomicUsize = AtomicUsize::new(0); +static DELEGATE_STRESS_SUCCEEDED: AtomicUsize = AtomicUsize::new(0); +static DELEGATE_STRESS_FAILED: AtomicUsize = AtomicUsize::new(0); + +struct TestPayload { + id: u32, +} + +impl Drop for TestPayload { + fn drop(&mut self) { + DROPPED.fetch_add(1, Ordering::SeqCst); + } +} + +#[napi(object)] +pub struct TsfnTestStats { + pub produced: u32, + pub dropped: u32, + pub accepted: u32, + pub queue_full: u32, + pub closing: u32, + pub other_failure: u32, +} + +#[napi(object)] +pub struct TsfnDelegateInvokeStats { + pub succeeded: u32, + pub failed: u32, +} + +fn count(value: &AtomicUsize) -> u32 { + value.load(Ordering::SeqCst).min(u32::MAX as usize) as u32 +} + +fn record_status(status: Status) { + match status { + Status::Ok => &ACCEPTED, + Status::QueueFull => &QUEUE_FULL, + Status::Closing => &CLOSING, + _ => &OTHER_FAILURE, + } + .fetch_add(1, Ordering::SeqCst); +} + +fn build_tsfn(callback: Function<'static, f64, ()>) -> napi::Result> { + build_tsfn_with_options(callback, 0, false) +} + +fn build_tsfn_with_options( + callback: Function<'static, f64, ()>, + max_queue_size: usize, + weak: bool, +) -> napi::Result> { + let env = callback.value().env; + let raw = napi::JsValue::raw(&callback); + ManagedTsfn::create( + env, + raw, + max_queue_size, + weak, + |value: TestPayload, env| { + unsafe { f64::to_napi_value(env, f64::from(value.id)) }.map(|value| vec![value]) + }, + None, + ) +} + +fn spawn_producer(tsfn: ManagedTsfn, count: u32, delay_ms: u32) { + thread::spawn(move || { + if delay_ms != 0 { + thread::sleep(Duration::from_millis(u64::from(delay_ms))); + } + for id in 0..count { + PRODUCED.fetch_add(1, Ordering::SeqCst); + record_status(tsfn.call(TestPayload { id })); + } + }); +} + +#[napi] +pub fn tsfn_test_reset() { + for counter in [ + &PRODUCED, + &DROPPED, + &ACCEPTED, + &QUEUE_FULL, + &CLOSING, + &OTHER_FAILURE, + ] { + counter.store(0, Ordering::SeqCst); + } +} + +#[napi] +pub fn tsfn_test_stats() -> TsfnTestStats { + TsfnTestStats { + produced: count(&PRODUCED), + dropped: count(&DROPPED), + accepted: count(&ACCEPTED), + queue_full: count(&QUEUE_FULL), + closing: count(&CLOSING), + other_failure: count(&OTHER_FAILURE), + } +} + +#[napi] +pub fn tsfn_test_start_unbounded( + callback: Function<'static, f64, ()>, + count: u32, + delay_ms: u32, +) -> napi::Result<()> { + spawn_producer( + build_tsfn_with_options(callback, 0, false)?, + count, + delay_ms, + ); + Ok(()) +} + +#[napi] +pub fn tsfn_test_start_bounded( + callback: Function<'static, f64, ()>, + count: u32, + delay_ms: u32, +) -> napi::Result<()> { + spawn_producer( + build_tsfn_with_options(callback, 1, false)?, + count, + delay_ms, + ); + Ok(()) +} + +#[napi] +pub fn tsfn_test_hold_strong(callback: Function<'static, f64, ()>) -> napi::Result<()> { + *HELD_STRONG + .lock() + .map_err(|_| napi::Error::from_reason("strong TSFN test lock is poisoned"))? = + Some(build_tsfn(callback)?); + Ok(()) +} + +#[napi] +pub fn tsfn_test_hold_weak(callback: Function<'static, f64, ()>) -> napi::Result<()> { + *HELD_WEAK + .lock() + .map_err(|_| napi::Error::from_reason("weak TSFN test lock is poisoned"))? = + Some(build_tsfn_with_options(callback, 0, true)?); + Ok(()) +} + +#[napi] +pub fn tsfn_test_release_held() { + HELD_STRONG + .lock() + .unwrap_or_else(|error| error.into_inner()) + .take(); + HELD_WEAK + .lock() + .unwrap_or_else(|error| error.into_inner()) + .take(); +} + +#[napi] +pub fn tsfn_test_registered_handle_count(env: Env) -> u32 { + managed_tsfn::test_registered_handle_count(env.raw()).min(u32::MAX as usize) as u32 +} + +#[napi] +pub fn tsfn_test_retain_delegate(delegate: &DynWinRtDelegate) -> napi::Result<()> { + let object = delegate + .0 + .as_object() + .ok_or_else(|| napi::Error::from_reason("TSFN test delegate is not a COM object"))? + .clone(); + let raw = object.into_raw() as usize; + let previous = RETAINED_DELEGATE.swap(raw, Ordering::SeqCst); + if previous != 0 { + unsafe { drop(IUnknown::from_raw(previous as *mut c_void)) }; + } + Ok(()) +} + +unsafe fn invoke_delegate(raw: *mut c_void) -> i32 { + let vtable = unsafe { *(raw as *const *const *const c_void) }; + let invoke: unsafe extern "system" fn(*mut c_void) -> windows::core::HRESULT = + unsafe { std::mem::transmute(*vtable.add(3)) }; + unsafe { invoke(raw) }.0 +} + +#[napi] +pub fn tsfn_test_invoke_retained_delegate() -> napi::Result { + let raw = RETAINED_DELEGATE.load(Ordering::SeqCst) as *mut c_void; + if raw.is_null() { + return Err(napi::Error::from_reason( + "No delegate is retained by the TSFN test harness", + )); + } + Ok(unsafe { invoke_delegate(raw) }) +} + +#[napi] +pub fn tsfn_test_invoke_retained_delegate_on_thread() -> napi::Result { + let raw = RETAINED_DELEGATE.load(Ordering::SeqCst); + if raw == 0 { + return Err(napi::Error::from_reason( + "No delegate is retained by the TSFN test harness", + )); + } + thread::spawn(move || unsafe { invoke_delegate(raw as *mut c_void) }) + .join() + .map_err(|_| napi::Error::from_reason("TSFN delegate test thread panicked")) +} + +#[napi] +pub fn tsfn_test_invoke_retained_delegate_on_thread_many( + count: u32, +) -> napi::Result { + let raw = RETAINED_DELEGATE.load(Ordering::SeqCst); + if raw == 0 { + return Err(napi::Error::from_reason( + "No delegate is retained by the TSFN test harness", + )); + } + thread::spawn(move || { + let mut succeeded = 0; + let mut failed = 0; + for _ in 0..count { + if unsafe { invoke_delegate(raw as *mut c_void) } == 0 { + succeeded += 1; + } else { + failed += 1; + } + } + TsfnDelegateInvokeStats { succeeded, failed } + }) + .join() + .map_err(|_| napi::Error::from_reason("TSFN delegate test thread panicked")) +} + +#[napi] +pub fn tsfn_test_start_retained_delegate_stress(count: u32) -> napi::Result<()> { + let raw = RETAINED_DELEGATE.load(Ordering::SeqCst); + if raw == 0 { + return Err(napi::Error::from_reason( + "No delegate is retained by the TSFN test harness", + )); + } + DELEGATE_STRESS_DONE.store(0, Ordering::SeqCst); + DELEGATE_STRESS_SUCCEEDED.store(0, Ordering::SeqCst); + DELEGATE_STRESS_FAILED.store(0, Ordering::SeqCst); + thread::spawn(move || { + for _ in 0..count { + if unsafe { invoke_delegate(raw as *mut c_void) } == 0 { + DELEGATE_STRESS_SUCCEEDED.fetch_add(1, Ordering::SeqCst); + } else { + DELEGATE_STRESS_FAILED.fetch_add(1, Ordering::SeqCst); + } + thread::yield_now(); + } + DELEGATE_STRESS_DONE.store(1, Ordering::SeqCst); + }); + Ok(()) +} + +#[napi] +pub fn tsfn_test_wait_retained_delegate_stress( + timeout_ms: u32, +) -> napi::Result { + let deadline = Instant::now() + Duration::from_millis(u64::from(timeout_ms)); + while DELEGATE_STRESS_DONE.load(Ordering::SeqCst) == 0 { + if Instant::now() >= deadline { + return Err(napi::Error::from_reason( + "Timed out waiting for the retained delegate stress thread", + )); + } + thread::sleep(Duration::from_millis(1)); + } + Ok(TsfnDelegateInvokeStats { + succeeded: count(&DELEGATE_STRESS_SUCCEEDED), + failed: count(&DELEGATE_STRESS_FAILED), + }) +} + +#[napi] +pub fn tsfn_test_release_retained_delegate() { + let raw = RETAINED_DELEGATE.swap(0, Ordering::SeqCst); + if raw != 0 { + unsafe { drop(IUnknown::from_raw(raw as *mut c_void)) }; + } +} + +#[napi] +pub fn tsfn_test_arm_call_pause() { + managed_tsfn::test_arm_call_pause(); +} + +#[napi] +pub fn tsfn_test_wait_call_paused(timeout_ms: u32) -> bool { + wait_until(timeout_ms, managed_tsfn::test_call_paused) +} + +#[napi] +pub fn tsfn_test_wait_cleanup_waiting(timeout_ms: u32) -> bool { + wait_until(timeout_ms, managed_tsfn::test_cleanup_waiting) +} + +#[napi] +pub fn tsfn_test_cleanup_acquired() -> bool { + managed_tsfn::test_cleanup_acquired() +} + +#[napi] +pub fn tsfn_test_release_call_pause() { + managed_tsfn::test_release_call_pause(); +} + +#[napi] +pub fn tsfn_test_wait_produced(expected: u32, timeout_ms: u32) -> bool { + wait_until(timeout_ms, || count(&PRODUCED) >= expected) +} + +fn wait_until(timeout_ms: u32, predicate: impl Fn() -> bool) -> bool { + let deadline = Instant::now() + Duration::from_millis(u64::from(timeout_ms)); + while !predicate() { + if Instant::now() >= deadline { + return false; + } + thread::sleep(Duration::from_millis(1)); + } + true +} diff --git a/crates/dynwinrt/src/array.rs b/crates/dynwinrt/src/array.rs index 6673e92a..ec6dc235 100644 --- a/crates/dynwinrt/src/array.rs +++ b/crates/dynwinrt/src/array.rs @@ -212,6 +212,26 @@ impl ArrayData { } } + /// Return the raw buffer as a typed slice when this array is CoTaskMem-backed + /// and the requested element width matches. + /// + /// # Safety + /// Caller must ensure `T` matches the semantic element type. + pub unsafe fn try_as_typed_slice(&self) -> Option<&[T]> { + match &self.buffer { + ArrayBuffer::CoTaskMem { ptr, len } + if std::mem::size_of::() == self.element_type.element_size() => + { + if *len == 0 { + Some(&[]) + } else { + Some(unsafe { std::slice::from_raw_parts(*ptr as *const T, *len) }) + } + } + ArrayBuffer::CoTaskMem { .. } | ArrayBuffer::Values(_) => None, + } + } + // ------------------------------------------------------------------ // Per-element access (works for all types) // ------------------------------------------------------------------ diff --git a/crates/dynwinrt/src/dasync.rs b/crates/dynwinrt/src/dasync.rs index b62981cc..75f64bfd 100644 --- a/crates/dynwinrt/src/dasync.rs +++ b/crates/dynwinrt/src/dasync.rs @@ -382,6 +382,9 @@ use crate::metadata_table::TypeHandle; /// Callback type for progress notifications. pub type ProgressCallback = Box; +/// Callback type for progress notifications that can report dispatch failure. +pub type ProgressResultCallback = Box HRESULT + Send + Sync>; + /// Create a progress handler for a WithProgress async operation. /// /// Reuses `delegate::create_delegate` — the progress handler is simply a @@ -394,6 +397,22 @@ pub fn create_progress_handler( handler_iid: GUID, progress_type: TypeHandle, callback: ProgressCallback, +) -> IUnknown { + create_progress_handler_with_result( + handler_iid, + progress_type, + Box::new(move |value| { + callback(value); + HRESULT(0) + }), + ) +} + +/// Create a progress handler whose callback HRESULT is returned to WinRT. +pub fn create_progress_handler_with_result( + handler_iid: GUID, + progress_type: TypeHandle, + callback: ProgressResultCallback, ) -> IUnknown { // Progress handler Invoke signature: (sender: Object, progress: TProgress) let sender_type = progress_type @@ -405,9 +424,10 @@ pub fn create_progress_handler( Box::new(move |args: &[WinRTValue]| { // args[0] = sender, args[1] = progress value if args.len() >= 2 { - callback(args[1].clone()); + callback(args[1].clone()) + } else { + HRESULT(0) } - HRESULT(0) }); crate::delegate::create_delegate(handler_iid, param_types, delegate_callback) @@ -598,6 +618,22 @@ mod tests { assert!(result.is_ok()); assert_eq!(received.load(Ordering::SeqCst), 42); + + let failing_handler = super::create_progress_handler_with_result( + handler_iid, + reg.make(TypeKind::U64), + Box::new(|_| HRESULT(0x80004005u32 as i32)), + ); + let failing_vtable = + unsafe { &**(failing_handler.as_raw() as *const *const ProgressHandlerVtbl) }; + let result = unsafe { + (failing_vtable.invoke)( + failing_handler.as_raw(), + std::ptr::null_mut(), + 42usize as *mut std::ffi::c_void, + ) + }; + assert_eq!(result, HRESULT(0x80004005u32 as i32)); } /// Test SetProgress on a real IAsyncOperationWithProgress using HTTP BufferAllAsync. diff --git a/crates/dynwinrt/src/lib.rs b/crates/dynwinrt/src/lib.rs index d7f3ea63..884f7e7f 100644 --- a/crates/dynwinrt/src/lib.rs +++ b/crates/dynwinrt/src/lib.rs @@ -35,8 +35,9 @@ pub use crate::composition::{ compose_winrt, compose_winrt_with_overrides, }; pub use crate::dasync::{ - AsyncCompletedCallback, ProgressCallback, WinRTAsyncFuture, create_progress_handler, - get_async_results, set_async_completed_handler, + AsyncCompletedCallback, ProgressCallback, ProgressResultCallback, WinRTAsyncFuture, + create_progress_handler, create_progress_handler_with_result, get_async_results, + set_async_completed_handler, }; pub use crate::dispatcher_queue::{SystemDispatcherQueue, SystemDispatcherQueueHandle}; pub use crate::element_factory::{ diff --git a/docs/status/TODO.md b/docs/status/TODO.md index 34356602..3925839e 100644 --- a/docs/status/TODO.md +++ b/docs/status/TODO.md @@ -24,15 +24,21 @@ _None currently. Reserved for issues that make v0.1 unshippable (crash on happy Fix pattern: at every COM ABI entry, validate each out-pointer against null and return `E_POINTER`; convert `.unwrap()` on incoming COM pointers to `Result` + `E_UNEXPECTED`. -- [ ] **JS `u64` round-trips through signed integers**. `to_u64_vec()` returns `Vec` and `from_u64_values()` takes `Vec` (`bindings/js/src/lib.rs:809-810, 892-895`). Also `DynWinRTValue::u64(value: i64)` at line 470 casts negatives to giant unsigned values silently. Values > `i64::MAX` are silent data corruption. Switch to `BigInt` or `u64` on the JS boundary. - -- [ ] **JS binding: TSFN failure returns success**. `bindings/js/src/lib.rs:1463` discards the return of `tsfn.call(...)` and always returns `HRESULT(0)`. When the JS event queue is closed or the env is tearing down, WinRT sees success but the callback is silently dropped. Map failures to `E_FAIL` / a cancellation code. - -- [ ] **JS binding: panic-shaped public APIs**. Two public methods still `panic!` on ordinary type mismatches, which propagates as a Node abort rather than a JS `throw`: - - `bindings/js/src/lib.rs:645` — `DynWinRTValue::to_number` for unsupported kinds - - `bindings/js/src/lib.rs:692` — `DynWinRTValue::as_raw` for non-object values - - Convert both to `napi::Result`. +- [x] **JS 64-bit integer round trips**. Scalar, array, and struct-field + boundaries accept range-checked `bigint` or safe integers. Array outputs + use `bigint[]`, preserving the complete signed and unsigned 64-bit ranges. + +- [x] **JS binding: TSFN failure propagation**. Delegate and progress callbacks + map a failed TSFN queue operation to `E_FAIL` instead of reporting + `S_OK` for a callback that was not accepted. The binding owns its TSFN + payload lifecycle, releases rejected and teardown-drained values, uses a + finite queue, and serializes calls with per-environment cleanup. + +- [x] **JS binding: panic-shaped public APIs**. Invalid value conversions, + raw-pointer access, primitive array conversions, array indexes, and + struct field accesses now return `napi::Result` errors. Struct fields + validate type, numeric range, and nested struct identity before entering + core accessors. - [ ] **Codegen: `extract_iid` silently zero-fills malformed GuidAttribute**. `tools/dynwinrt-codegen/src/meta.rs:1030-1051` — if any GuidAttribute field is the wrong integer width, helpers return `0`, producing a plausible-but-wrong IID that will corrupt interface registration without any error. Treat non-matching shapes as a hard error / empty IID. @@ -58,7 +64,11 @@ _None currently. Reserved for issues that make v0.1 unshippable (crash on happy - [ ] **Rust: map key semantics under-specified**. `crates/dynwinrt/src/map.rs:79-120` — pointer identity is the default, with an ad-hoc string extraction path for `IPropertyValue`. Define one contract (identity vs value equality) and enforce it explicitly. -- [ ] **JS: N-API result codes ignored on same-thread delegate path**. `bindings/js/src/lib.rs:1416-1448` — `napi_get_undefined`, `napi_is_exception_pending`, `napi_get_and_clear_last_exception`, `napi_close_handle_scope` return statuses are dropped. Any failure can leave a pending exception across the ABI while still returning `S_OK`. Check every status. +- [x] **JS: same-thread delegate N-API status handling**. Global lookup, + callback invocation, exception inspection/clearing, fatal exception + forwarding, and handle-scope closure are checked and mapped to a failing + HRESULT when dispatch cannot complete. Each delegate also carries a + `napi_async_context`, preserving `async_hooks` and `AsyncLocalStorage`. - [ ] **JS: `DynWinRtStruct::set_object` silently no-ops**. `bindings/js/src/lib.rs:1103-1125` — unsupported input kinds hit `_ => {}`. Return `napi::Result<()>` with a clear error.