From 6a2f1a04a7adb9ef8febfe9264b01e8ad6be7482 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Sun, 13 Sep 2026 14:04:58 -0400 Subject: [PATCH 1/2] Replace Koffi with ffi-rs in Node SDK Migrate the in-process runtime transport to ffi-rs, preserve callback and cleanup lifecycle behavior, and add coverage for callback pointer handling and partial startup rollback. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- nodejs/package-lock.json | 485 +++++++++++------------------ nodejs/package.json | 2 +- nodejs/samples/package-lock.json | 320 +------------------ nodejs/src/client.ts | 4 +- nodejs/src/ffiRuntimeHost.ts | 395 ++++++++++++++--------- nodejs/test/ffiRuntimeHost.test.ts | 289 ++++++++++------- 6 files changed, 607 insertions(+), 888 deletions(-) diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index 0de83bc883..03773c78dc 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -9,7 +9,7 @@ "version": "0.0.0-dev", "license": "MIT", "dependencies": { - "koffi": "^3.2.1", + "ffi-rs": "^1.3.7", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" }, @@ -735,276 +735,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@koromix/koffi-android-arm64": { - "version": "3.2.1", - "integrity": "sha512-1pJQ4jnZlUJduK9u9DC5CGy3aOgDUPvIXpNb6syV3+Dh5Q/ugezAIGCqvY+w+1mgXsve0pd0NVvJRjdZNHQ6MA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "funding": { - "url": "https://liberapay.com/Koromix" - } - }, - "node_modules/@koromix/koffi-android-x64": { - "version": "3.2.1", - "integrity": "sha512-HH40xGh3gVQifjOBnhwT2tECC0lL1lYe+nxHvWNSzxDIyQNcVPXg38ta7vuONRFpD+uIrw7fqGYLzbZIagkVcg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "funding": { - "url": "https://liberapay.com/Koromix" - } - }, - "node_modules/@koromix/koffi-darwin-arm64": { - "version": "3.2.1", - "integrity": "sha512-Vj4h+xcjc5+Cn0DhPHjgRX4omKAv96Kehtcd+1YgYuY2W7FvQn9vS+3SmzVwhC5Qmg9bIwUZObYQ8T/4hBqQqA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://liberapay.com/Koromix" - } - }, - "node_modules/@koromix/koffi-darwin-x64": { - "version": "3.2.1", - "integrity": "sha512-gFCWxNBTZIvxo1p+PURWfsy2Ctj5FGnVVs1f03lTLhBvmxEto70pdIiFztdFLDFkAJ1pmtQmruRKapeK+E8YPA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://liberapay.com/Koromix" - } - }, - "node_modules/@koromix/koffi-freebsd-arm64": { - "version": "3.2.1", - "integrity": "sha512-qj+f1s2e6vULaUG1cdlTcCXmunCq2t+rjxku1+esaMIqVnHpOwj0QzPuInG0AFdXjwBNQhyVR/HpDj8daEwwsQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "funding": { - "url": "https://liberapay.com/Koromix" - } - }, - "node_modules/@koromix/koffi-freebsd-ia32": { - "version": "3.2.1", - "integrity": "sha512-6olHb1Qfgai0jjs6ddlDDD0ZfsCxy7SPi8rMRpuYQWH0qhgtyQu82hw5b1p7z+TJ0zZP3ZeQQ6l+U/MlM1ICHQ==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "funding": { - "url": "https://liberapay.com/Koromix" - } - }, - "node_modules/@koromix/koffi-freebsd-x64": { - "version": "3.2.1", - "integrity": "sha512-Dikhw1ySYNVMkmeFvFVjnU5Wdk6mffNoOjJxm9bTG96vg7OlemylxqdEven47R1YJ3yzNVJn/MlQ207ORWfi2w==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "funding": { - "url": "https://liberapay.com/Koromix" - } - }, - "node_modules/@koromix/koffi-linux-arm": { - "version": "3.2.1", - "integrity": "sha512-OfwUwZylidq95wQKp6ClInULrfB2giu7dqM6Rhe0zAe6lES5I2SXNw15T9+GnRHk3/9hKT2XZ37OZLaKSyWNLA==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://liberapay.com/Koromix" - } - }, - "node_modules/@koromix/koffi-linux-arm64": { - "version": "3.2.1", - "integrity": "sha512-K+cGUL5iBcDqxmsocrjmlASqDf24gc7artbVW3PewG2c9AqwC63lezgwvB85Nx4lZAQjB6zIFHh9A7t1yGbwhw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://liberapay.com/Koromix" - } - }, - "node_modules/@koromix/koffi-linux-ia32": { - "version": "3.2.1", - "integrity": "sha512-rxj6UYjU1qd98gxNQOSCdLpc5cPRi5Giq9rNd3jnGuSNIyMkwa6Dxw4cUjmhIBCYESMJtmNt5NWnJp5u9wTfYQ==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://liberapay.com/Koromix" - } - }, - "node_modules/@koromix/koffi-linux-loong64": { - "version": "3.2.1", - "integrity": "sha512-aHhnHzkPRmT/IHDlGvESJ/Bs32m8N6UE6Ab6kMeJzgk74IN8af2m/81/wZJtybR1M2UxCV4NmlVNUYQQvSAO3Q==", - "cpu": [ - "loong64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://liberapay.com/Koromix" - } - }, - "node_modules/@koromix/koffi-linux-riscv64": { - "version": "3.2.1", - "integrity": "sha512-qtQBsjbm3LiirLJvajWmKkNb7ARk7fvJVXdftJ7NtAnF3Xw8EbDvrtvmvtNI1yLPlYcBmlzCCD71hwhWYk0SIA==", - "cpu": [ - "riscv64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://liberapay.com/Koromix" - } - }, - "node_modules/@koromix/koffi-linux-x64": { - "version": "3.2.1", - "integrity": "sha512-c7hw7Qs/r5gnFRTQLcbifBwRU7wiocj+2pVuDQ5Ahb3r36SZmupmgYbTWcLvTW+hul1jd7SKRV0d14ZJq/tvSw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://liberapay.com/Koromix" - } - }, - "node_modules/@koromix/koffi-openbsd-ia32": { - "version": "3.2.1", - "integrity": "sha512-mmY8fY8LQ/CB52+h3yrMYmVyoxzW3x08S0yI6VNfHWdfU6yJtZkKCbhjmQCYMrWbYKC4gMvwZwCywIGPAkLyeA==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "funding": { - "url": "https://liberapay.com/Koromix" - } - }, - "node_modules/@koromix/koffi-openbsd-x64": { - "version": "3.2.1", - "integrity": "sha512-k4ig6aAPbFSRATOIIOfdf/KtlOGH4SVls6L9fy0QnTxRJYvY2oSltTsQtBDANgEQldlq8Kl5WnpRa1VSibP4Lw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "funding": { - "url": "https://liberapay.com/Koromix" - } - }, - "node_modules/@koromix/koffi-win32-arm64": { - "version": "3.2.1", - "integrity": "sha512-cTWBJGK//pDMeKQJE/79Aq9MiOAF4H8QyLZHSQ9IWm8czOfwjG4J1AhsQ9DjI9KFOykH77hhnpmQTGVMIubGig==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "funding": { - "url": "https://liberapay.com/Koromix" - } - }, - "node_modules/@koromix/koffi-win32-ia32": { - "version": "3.2.1", - "integrity": "sha512-Z50EM6TAZ7CFyMmyX6thv8eNpJchqe9eenhibSIy2Eq/FQYF76gU2VK/LEoaF46L8hfC7TpTp9b10MvReHEyFA==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "funding": { - "url": "https://liberapay.com/Koromix" - } - }, - "node_modules/@koromix/koffi-win32-x64": { - "version": "3.2.1", - "integrity": "sha512-ZmZNiBO6bkOSh3QNzgfvb1cMY0yMobn6ZQrSMqbAce21qyYL8niIbyipz9N/PIRDciGhsV0wUxnZsxIO+yWsHQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "funding": { - "url": "https://liberapay.com/Koromix" - } - }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.2.3", "integrity": "sha1-l+PUXXQk3F2h1OMvO/OykvbBtEw=", @@ -1688,6 +1418,172 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@yuuang/ffi-rs-android-arm64": { + "version": "1.3.7", + "integrity": "sha512-t6Wx3Xll6c07Nuk0k3xnZsxKFxlshm92i0U/BiTHc6kQbvu+fMJF+gKsj4yEj886jH51CM3EqZT9Xdhq9CdUVw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12" + } + }, + "node_modules/@yuuang/ffi-rs-darwin-arm64": { + "version": "1.3.7", + "integrity": "sha512-OueBlUFBT9IwD9pQnoYs0UszRBEySskfrEPXXfvKfGjL/DXnfn6kUheQ3oIP6sSmshVGNQUwrTCPo6feAa4QjA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12" + } + }, + "node_modules/@yuuang/ffi-rs-darwin-x64": { + "version": "1.3.7", + "integrity": "sha512-x4mxXOKwSgwYx6OBAKrW+Ocp8um2KoPpUwaD0Z2tmR6EekoRvFGN7s/eEK1avqCxcsLN7/MzGOnCCFyGpOVfPA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12" + } + }, + "node_modules/@yuuang/ffi-rs-linux-arm-gnueabihf": { + "version": "1.3.7", + "integrity": "sha512-9r//Z022QYVoIAY458Dpk8HWkisk/y4NJC3d52RlkdsLD+9p0zn0cKKSoMF/xUsOWDFSmOf+cwU/Xadi/wxFMQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12" + } + }, + "node_modules/@yuuang/ffi-rs-linux-arm64-gnu": { + "version": "1.3.7", + "integrity": "sha512-nrW4MlFlyQInfxTsI5wtcIpbV4KlHLWo2BCk4CHIETlUT+PUspNFqDg+Byy7My3hpCZlXp0WsMVP/3N0LlybNw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12" + } + }, + "node_modules/@yuuang/ffi-rs-linux-arm64-musl": { + "version": "1.3.7", + "integrity": "sha512-eLLx4P8DzNnuPif19nAvKPaf88lSf6KFbDetH3nAIRZvI495h0jigN835Ayr7uAADssohGfO9MPMH33DXmFErA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12" + } + }, + "node_modules/@yuuang/ffi-rs-linux-x64-gnu": { + "version": "1.3.7", + "integrity": "sha512-upEz1Q98T51x2In872fsjgHCJbE3e8r9JxhJD+5NvSMk6BpieYWRodTbsMxV6oKWmfl12loKwF2j4OBSWKl0lA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12" + } + }, + "node_modules/@yuuang/ffi-rs-linux-x64-musl": { + "version": "1.3.7", + "integrity": "sha512-YOcOkIwVvIhpwuGJ428h15vpE0KnT3teFG0g2J31FrBxrVh9IH4HeTGXjelI3l9SI5gyiZDnD4PQnQ1U6oqTcA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12" + } + }, + "node_modules/@yuuang/ffi-rs-win32-arm64-msvc": { + "version": "1.3.7", + "integrity": "sha512-ZfYzLV1w7Mhzh9XRScEMpw9oazIEiwO+0+TYHHRL882d5oD0Q99YikmRhdifsoHCCQefKmWF/4BXCeFkzg8NFA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12" + } + }, + "node_modules/@yuuang/ffi-rs-win32-ia32-msvc": { + "version": "1.3.7", + "integrity": "sha512-ifYz+f+giKpT39lKWDru+om6QnA9J7J953ns60xHgB46ESZvPd/Y2W5+PesRNC9U+vnFEiLZ9CNC+R3fBWC1pg==", + "cpu": [ + "x64", + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12" + } + }, + "node_modules/@yuuang/ffi-rs-win32-x64-msvc": { + "version": "1.3.7", + "integrity": "sha512-H3s4wOLtZtKtmuPjUNE+bhnSKxxpXcuxG70jRC8dUMHqIm5iCvdoZ4XYDeq7/uUYlySerXFmRL4dAVCX/TUNTg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12" + } + }, "node_modules/abort-controller": { "version": "3.0.0", "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", @@ -2288,6 +2184,24 @@ } } }, + "node_modules/ffi-rs": { + "version": "1.3.7", + "integrity": "sha512-5MMZQS2t/6f/ec3sx0f51gBcKGZPGfolPAW04lvLOSYnr7gmOKs8YzH1Rh+34WGO+VtcgcTuxaUUoJLcR4Tirg==", + "license": "MIT", + "optionalDependencies": { + "@yuuang/ffi-rs-android-arm64": "1.3.7", + "@yuuang/ffi-rs-darwin-arm64": "1.3.7", + "@yuuang/ffi-rs-darwin-x64": "1.3.7", + "@yuuang/ffi-rs-linux-arm-gnueabihf": "1.3.7", + "@yuuang/ffi-rs-linux-arm64-gnu": "1.3.7", + "@yuuang/ffi-rs-linux-arm64-musl": "1.3.7", + "@yuuang/ffi-rs-linux-x64-gnu": "1.3.7", + "@yuuang/ffi-rs-linux-x64-musl": "1.3.7", + "@yuuang/ffi-rs-win32-arm64-msvc": "1.3.7", + "@yuuang/ffi-rs-win32-ia32-msvc": "1.3.7", + "@yuuang/ffi-rs-win32-x64-msvc": "1.3.7" + } + }, "node_modules/file-entry-cache": { "version": "8.0.0", "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", @@ -2570,35 +2484,6 @@ "json-buffer": "3.0.1" } }, - "node_modules/koffi": { - "version": "3.2.1", - "integrity": "sha512-0qE3lZ8jllRqPN4Ob6Ajl7c2bJSJDhQWuKLGP5hIEpHLllJWv1ydHFMhHmHc5p/W9GticKVDbYzZd7TBoQ4CZg==", - "hasInstallScript": true, - "license": "MIT", - "funding": { - "url": "https://liberapay.com/Koromix" - }, - "optionalDependencies": { - "@koromix/koffi-android-arm64": "3.2.1", - "@koromix/koffi-android-x64": "3.2.1", - "@koromix/koffi-darwin-arm64": "3.2.1", - "@koromix/koffi-darwin-x64": "3.2.1", - "@koromix/koffi-freebsd-arm64": "3.2.1", - "@koromix/koffi-freebsd-ia32": "3.2.1", - "@koromix/koffi-freebsd-x64": "3.2.1", - "@koromix/koffi-linux-arm": "3.2.1", - "@koromix/koffi-linux-arm64": "3.2.1", - "@koromix/koffi-linux-ia32": "3.2.1", - "@koromix/koffi-linux-loong64": "3.2.1", - "@koromix/koffi-linux-riscv64": "3.2.1", - "@koromix/koffi-linux-x64": "3.2.1", - "@koromix/koffi-openbsd-ia32": "3.2.1", - "@koromix/koffi-openbsd-x64": "3.2.1", - "@koromix/koffi-win32-arm64": "3.2.1", - "@koromix/koffi-win32-ia32": "3.2.1", - "@koromix/koffi-win32-x64": "3.2.1" - } - }, "node_modules/levn": { "version": "0.4.1", "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", diff --git a/nodejs/package.json b/nodejs/package.json index 8805d0d4d8..c99636fcda 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -62,7 +62,7 @@ "author": "GitHub", "license": "MIT", "dependencies": { - "koffi": "^3.2.1", + "ffi-rs": "^1.3.7", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" }, diff --git a/nodejs/samples/package-lock.json b/nodejs/samples/package-lock.json index 6c29f98383..f12e9cda18 100644 --- a/nodejs/samples/package-lock.json +++ b/nodejs/samples/package-lock.json @@ -18,7 +18,7 @@ "version": "0.0.0-dev", "license": "MIT", "dependencies": { - "koffi": "^3.2.1", + "ffi-rs": "^1.3.7", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" }, @@ -291,294 +291,6 @@ "dev": true, "license": "MIT" }, - "../node_modules/@koromix/koffi-android-arm64": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/@koromix/koffi-android-arm64/-/koffi-android-arm64-3.2.1.tgz", - "integrity": "sha512-1pJQ4jnZlUJduK9u9DC5CGy3aOgDUPvIXpNb6syV3+Dh5Q/ugezAIGCqvY+w+1mgXsve0pd0NVvJRjdZNHQ6MA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "funding": { - "url": "https://liberapay.com/Koromix" - } - }, - "../node_modules/@koromix/koffi-android-x64": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/@koromix/koffi-android-x64/-/koffi-android-x64-3.2.1.tgz", - "integrity": "sha512-HH40xGh3gVQifjOBnhwT2tECC0lL1lYe+nxHvWNSzxDIyQNcVPXg38ta7vuONRFpD+uIrw7fqGYLzbZIagkVcg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "funding": { - "url": "https://liberapay.com/Koromix" - } - }, - "../node_modules/@koromix/koffi-darwin-arm64": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/@koromix/koffi-darwin-arm64/-/koffi-darwin-arm64-3.2.1.tgz", - "integrity": "sha512-Vj4h+xcjc5+Cn0DhPHjgRX4omKAv96Kehtcd+1YgYuY2W7FvQn9vS+3SmzVwhC5Qmg9bIwUZObYQ8T/4hBqQqA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://liberapay.com/Koromix" - } - }, - "../node_modules/@koromix/koffi-darwin-x64": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/@koromix/koffi-darwin-x64/-/koffi-darwin-x64-3.2.1.tgz", - "integrity": "sha512-gFCWxNBTZIvxo1p+PURWfsy2Ctj5FGnVVs1f03lTLhBvmxEto70pdIiFztdFLDFkAJ1pmtQmruRKapeK+E8YPA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://liberapay.com/Koromix" - } - }, - "../node_modules/@koromix/koffi-freebsd-arm64": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-arm64/-/koffi-freebsd-arm64-3.2.1.tgz", - "integrity": "sha512-qj+f1s2e6vULaUG1cdlTcCXmunCq2t+rjxku1+esaMIqVnHpOwj0QzPuInG0AFdXjwBNQhyVR/HpDj8daEwwsQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "funding": { - "url": "https://liberapay.com/Koromix" - } - }, - "../node_modules/@koromix/koffi-freebsd-ia32": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-ia32/-/koffi-freebsd-ia32-3.2.1.tgz", - "integrity": "sha512-6olHb1Qfgai0jjs6ddlDDD0ZfsCxy7SPi8rMRpuYQWH0qhgtyQu82hw5b1p7z+TJ0zZP3ZeQQ6l+U/MlM1ICHQ==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "funding": { - "url": "https://liberapay.com/Koromix" - } - }, - "../node_modules/@koromix/koffi-freebsd-x64": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-x64/-/koffi-freebsd-x64-3.2.1.tgz", - "integrity": "sha512-Dikhw1ySYNVMkmeFvFVjnU5Wdk6mffNoOjJxm9bTG96vg7OlemylxqdEven47R1YJ3yzNVJn/MlQ207ORWfi2w==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "funding": { - "url": "https://liberapay.com/Koromix" - } - }, - "../node_modules/@koromix/koffi-linux-arm": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-arm/-/koffi-linux-arm-3.2.1.tgz", - "integrity": "sha512-OfwUwZylidq95wQKp6ClInULrfB2giu7dqM6Rhe0zAe6lES5I2SXNw15T9+GnRHk3/9hKT2XZ37OZLaKSyWNLA==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://liberapay.com/Koromix" - } - }, - "../node_modules/@koromix/koffi-linux-arm64": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-arm64/-/koffi-linux-arm64-3.2.1.tgz", - "integrity": "sha512-K+cGUL5iBcDqxmsocrjmlASqDf24gc7artbVW3PewG2c9AqwC63lezgwvB85Nx4lZAQjB6zIFHh9A7t1yGbwhw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://liberapay.com/Koromix" - } - }, - "../node_modules/@koromix/koffi-linux-ia32": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-ia32/-/koffi-linux-ia32-3.2.1.tgz", - "integrity": "sha512-rxj6UYjU1qd98gxNQOSCdLpc5cPRi5Giq9rNd3jnGuSNIyMkwa6Dxw4cUjmhIBCYESMJtmNt5NWnJp5u9wTfYQ==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://liberapay.com/Koromix" - } - }, - "../node_modules/@koromix/koffi-linux-loong64": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-loong64/-/koffi-linux-loong64-3.2.1.tgz", - "integrity": "sha512-aHhnHzkPRmT/IHDlGvESJ/Bs32m8N6UE6Ab6kMeJzgk74IN8af2m/81/wZJtybR1M2UxCV4NmlVNUYQQvSAO3Q==", - "cpu": [ - "loong64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://liberapay.com/Koromix" - } - }, - "../node_modules/@koromix/koffi-linux-riscv64": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-riscv64/-/koffi-linux-riscv64-3.2.1.tgz", - "integrity": "sha512-qtQBsjbm3LiirLJvajWmKkNb7ARk7fvJVXdftJ7NtAnF3Xw8EbDvrtvmvtNI1yLPlYcBmlzCCD71hwhWYk0SIA==", - "cpu": [ - "riscv64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://liberapay.com/Koromix" - } - }, - "../node_modules/@koromix/koffi-linux-x64": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-x64/-/koffi-linux-x64-3.2.1.tgz", - "integrity": "sha512-c7hw7Qs/r5gnFRTQLcbifBwRU7wiocj+2pVuDQ5Ahb3r36SZmupmgYbTWcLvTW+hul1jd7SKRV0d14ZJq/tvSw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://liberapay.com/Koromix" - } - }, - "../node_modules/@koromix/koffi-openbsd-ia32": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/@koromix/koffi-openbsd-ia32/-/koffi-openbsd-ia32-3.2.1.tgz", - "integrity": "sha512-mmY8fY8LQ/CB52+h3yrMYmVyoxzW3x08S0yI6VNfHWdfU6yJtZkKCbhjmQCYMrWbYKC4gMvwZwCywIGPAkLyeA==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "funding": { - "url": "https://liberapay.com/Koromix" - } - }, - "../node_modules/@koromix/koffi-openbsd-x64": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/@koromix/koffi-openbsd-x64/-/koffi-openbsd-x64-3.2.1.tgz", - "integrity": "sha512-k4ig6aAPbFSRATOIIOfdf/KtlOGH4SVls6L9fy0QnTxRJYvY2oSltTsQtBDANgEQldlq8Kl5WnpRa1VSibP4Lw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "funding": { - "url": "https://liberapay.com/Koromix" - } - }, - "../node_modules/@koromix/koffi-win32-arm64": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/@koromix/koffi-win32-arm64/-/koffi-win32-arm64-3.2.1.tgz", - "integrity": "sha512-cTWBJGK//pDMeKQJE/79Aq9MiOAF4H8QyLZHSQ9IWm8czOfwjG4J1AhsQ9DjI9KFOykH77hhnpmQTGVMIubGig==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "funding": { - "url": "https://liberapay.com/Koromix" - } - }, - "../node_modules/@koromix/koffi-win32-ia32": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/@koromix/koffi-win32-ia32/-/koffi-win32-ia32-3.2.1.tgz", - "integrity": "sha512-Z50EM6TAZ7CFyMmyX6thv8eNpJchqe9eenhibSIy2Eq/FQYF76gU2VK/LEoaF46L8hfC7TpTp9b10MvReHEyFA==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "funding": { - "url": "https://liberapay.com/Koromix" - } - }, - "../node_modules/@koromix/koffi-win32-x64": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/@koromix/koffi-win32-x64/-/koffi-win32-x64-3.2.1.tgz", - "integrity": "sha512-ZmZNiBO6bkOSh3QNzgfvb1cMY0yMobn6ZQrSMqbAce21qyYL8niIbyipz9N/PIRDciGhsV0wUxnZsxIO+yWsHQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "funding": { - "url": "https://liberapay.com/Koromix" - } - }, "../node_modules/@oxc-project/types": { "version": "0.133.0", "dev": true, @@ -1782,36 +1494,6 @@ "json-buffer": "3.0.1" } }, - "../node_modules/koffi": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/koffi/-/koffi-3.2.1.tgz", - "integrity": "sha512-0qE3lZ8jllRqPN4Ob6Ajl7c2bJSJDhQWuKLGP5hIEpHLllJWv1ydHFMhHmHc5p/W9GticKVDbYzZd7TBoQ4CZg==", - "hasInstallScript": true, - "license": "MIT", - "funding": { - "url": "https://liberapay.com/Koromix" - }, - "optionalDependencies": { - "@koromix/koffi-android-arm64": "3.2.1", - "@koromix/koffi-android-x64": "3.2.1", - "@koromix/koffi-darwin-arm64": "3.2.1", - "@koromix/koffi-darwin-x64": "3.2.1", - "@koromix/koffi-freebsd-arm64": "3.2.1", - "@koromix/koffi-freebsd-ia32": "3.2.1", - "@koromix/koffi-freebsd-x64": "3.2.1", - "@koromix/koffi-linux-arm": "3.2.1", - "@koromix/koffi-linux-arm64": "3.2.1", - "@koromix/koffi-linux-ia32": "3.2.1", - "@koromix/koffi-linux-loong64": "3.2.1", - "@koromix/koffi-linux-riscv64": "3.2.1", - "@koromix/koffi-linux-x64": "3.2.1", - "@koromix/koffi-openbsd-ia32": "3.2.1", - "@koromix/koffi-openbsd-x64": "3.2.1", - "@koromix/koffi-win32-arm64": "3.2.1", - "@koromix/koffi-win32-ia32": "3.2.1", - "@koromix/koffi-win32-x64": "3.2.1" - } - }, "../node_modules/levn": { "version": "0.4.1", "dev": true, diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 6e4b4fb5b4..5258864d83 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -2829,8 +2829,8 @@ export class CopilotClient { } else { runtimeLibrary = join(dirname(await getBundledRuntimePath()), "runtime.node"); } - // Load the FFI host lazily so the native `koffi` addon (and its - // platform-specific `koffi.node`) is only loaded on the in-process path; + // Load the FFI host lazily so the native `ffi-rs` addon is only loaded + // on the in-process path; // out-of-process (stdio/tcp) consumers never touch the native dependency. // The transpiled output is per-file (not bundled), so this resolves the // sibling module at runtime in both the ESM and CJS builds. diff --git a/nodejs/src/ffiRuntimeHost.ts b/nodejs/src/ffiRuntimeHost.ts index f2db8eb163..a066de22f5 100644 --- a/nodejs/src/ffiRuntimeHost.ts +++ b/nodejs/src/ffiRuntimeHost.ts @@ -16,28 +16,50 @@ */ import { existsSync } from "node:fs"; -import koffi from "koffi"; +import { + DataType, + PointerType, + arrayConstructor, + createExternalBuffer, + createPointer, + freePointer, + funcConstructor, + load, + open, + restorePointer, + unwrapPointer, + type FuncConstructorOptions, + type JsExternal, +} from "ffi-rs"; import { resolve } from "node:path"; import { PassThrough, Writable } from "node:stream"; const SYMBOL_PREFIX = "copilot_runtime_"; - -// A long, referenced no-op timer keeps the Node event loop alive while the in-process -// connection is open (see start()); the exact interval is irrelevant. -const KEEP_ALIVE_INTERVAL_MS = 1 << 30; +const LIBRARY_KEY_PREFIX = "@github/copilot-sdk/runtime:"; const CLEANUP_RETRY_INTERVAL_MS = 100; -type KoffiFunction = ReturnType["func"]>; -type KoffiType = ReturnType; -type KoffiRegisteredCallback = ReturnType; - interface FfiLibrary { - hostStart: KoffiFunction; - hostShutdown: KoffiFunction; - connectionOpen: KoffiFunction; - connectionWrite: KoffiFunction; - connectionClose: KoffiFunction; - outboundCallbackType: KoffiType; + hostStart( + argv: Buffer, + argvLength: number, + environment: Buffer, + environmentLength: number + ): Promise; + hostShutdown(serverId: number): boolean; + connectionOpen( + serverId: number, + callback: JsExternal, + userData: JsExternal, + config: Buffer, + configLength: number, + auth: Buffer, + authLength: number, + additional: Buffer, + additionalLength: number + ): number; + connectionWrite(connectionId: number, frame: Buffer, frameLength: number): boolean; + connectionClose(connectionId: number): Promise; + outboundCallbackType: FuncConstructorOptions; } let loadedLibraryPath: string | undefined; @@ -58,38 +80,92 @@ function loadLibrary(libraryPath: string): FfiLibrary { return loadedLibrary; } - const lib = koffi.load(libraryPath); - const outboundCallbackType = koffi.pointer( - koffi.proto( - `void ${SYMBOL_PREFIX}outbound(void *userData, uint8 *bytesPtr, size_t bytesLen)` - ) - ); + const libraryKey = `${LIBRARY_KEY_PREFIX}${libraryPath}`; + open({ library: libraryKey, path: libraryPath }); + const outboundCallbackType = funcConstructor({ + // ffi-rs cannot create External values on a native callback thread. Pointer + // values are ABI-equivalent to i64 on every platform supported by this host, + // and BigInt preserves all pointer bits when crossing into JavaScript. + paramsType: [DataType.BigInt, DataType.BigInt, DataType.U64], + // ffi-rs 1.3.7 corrupts the first pointer-sized bytes of callback-owned memory + // for void callbacks. The native caller ignores the return register, so use a + // u64 return and always return zero. + retType: DataType.U64, + }); loadedLibrary = { - hostStart: lib.func(`${SYMBOL_PREFIX}host_start`, "uint32", [ - "uint8*", - "size_t", - "uint8*", - "size_t", - ]), - hostShutdown: lib.func(`${SYMBOL_PREFIX}host_shutdown`, "bool", ["uint32"]), - connectionOpen: lib.func(`${SYMBOL_PREFIX}connection_open`, "uint32", [ - "uint32", - outboundCallbackType, - "void*", - "uint8*", - "size_t", - "uint8*", - "size_t", - "uint8*", - "size_t", - ]), - connectionWrite: lib.func(`${SYMBOL_PREFIX}connection_write`, "bool", [ - "uint32", - "uint8*", - "size_t", - ]), - connectionClose: lib.func(`${SYMBOL_PREFIX}connection_close`, "bool", ["uint32"]), + hostStart: (argv, argvLength, environment, environmentLength) => + load({ + library: libraryKey, + funcName: `${SYMBOL_PREFIX}host_start`, + retType: DataType.U32, + paramsType: [DataType.U8Array, DataType.U64, DataType.U8Array, DataType.U64], + paramsValue: [argv, argvLength, environment, environmentLength], + runInNewThread: true, + }), + hostShutdown: (serverId) => + load({ + library: libraryKey, + funcName: `${SYMBOL_PREFIX}host_shutdown`, + retType: DataType.Boolean, + paramsType: [DataType.U32], + paramsValue: [serverId], + }), + connectionOpen: ( + serverId, + callback, + userData, + config, + configLength, + auth, + authLength, + additional, + additionalLength + ) => + load({ + library: libraryKey, + funcName: `${SYMBOL_PREFIX}connection_open`, + retType: DataType.U32, + paramsType: [ + DataType.U32, + DataType.External, + DataType.External, + DataType.U8Array, + DataType.U64, + DataType.U8Array, + DataType.U64, + DataType.U8Array, + DataType.U64, + ], + paramsValue: [ + serverId, + callback, + userData, + config, + configLength, + auth, + authLength, + additional, + additionalLength, + ], + }), + connectionWrite: (connectionId, frame, frameLength) => + load({ + library: libraryKey, + funcName: `${SYMBOL_PREFIX}connection_write`, + retType: DataType.Boolean, + paramsType: [DataType.U32, DataType.U8Array, DataType.U64], + paramsValue: [connectionId, frame, frameLength], + }), + connectionClose: (connectionId) => + load({ + library: libraryKey, + funcName: `${SYMBOL_PREFIX}connection_close`, + retType: DataType.Boolean, + paramsType: [DataType.U32], + paramsValue: [connectionId], + runInNewThread: true, + }), outboundCallbackType, }; loadedLibraryPath = libraryPath; @@ -106,9 +182,9 @@ function buildArgvJson(cliEntrypoint: string | undefined, args: readonly string[ return Buffer.from(JSON.stringify(argv), "utf8"); } -function buildEnvJson(environment?: Record): Buffer | null { +function buildEnvJson(environment?: Record): Buffer { if (!environment) { - return null; + return Buffer.alloc(0); } const obj: Record = {}; for (const [key, value] of Object.entries(environment)) { @@ -117,7 +193,7 @@ function buildEnvJson(environment?: Record): Buffer } } if (Object.keys(obj).length === 0) { - return null; + return Buffer.alloc(0); } return Buffer.from(JSON.stringify(obj), "utf8"); } @@ -130,8 +206,10 @@ export class FfiRuntimeHost { private connectionId = 0; private disposed = false; private starting = false; - private outboundCallback: KoffiRegisteredCallback | undefined; - private keepAliveTimer: ReturnType | undefined; + private outboundCallback: JsExternal[] | undefined; + private outboundCallbackPointer: JsExternal | undefined; + private inboundAddressOwner: JsExternal[] | undefined; + private inboundAddressSlot: Buffer | undefined; private cleanupRetryTimer: ReturnType | undefined; private cleanupInProgress = false; @@ -200,21 +278,12 @@ export class FfiRuntimeHost { // host_start constructs the native engine synchronously; run it as an async FFI // call so the Node event loop isn't blocked. - this.serverId = await new Promise((resolvePromise, rejectPromise) => { - this.lib.hostStart.async( - argvJson, - argvJson.length, - envJson, - envJson ? envJson.length : 0, - (error: Error | null, result: number) => { - if (error) { - rejectPromise(error); - } else { - resolvePromise(result); - } - } - ); - }); + this.serverId = await this.lib.hostStart( + argvJson, + argvJson.length, + envJson, + envJson.length + ); if (!this.serverId) { throw new Error( `copilot_runtime_host_start failed (library '${this.libraryPath}').` @@ -224,35 +293,43 @@ export class FfiRuntimeHost { throw new Error("The in-process runtime host was disposed during startup."); } - this.outboundCallback = koffi.register( - (_userData: unknown, bytesPtr: unknown, bytesLen: number | bigint) => - this.feedInbound(bytesPtr, bytesLen), - this.lib.outboundCallbackType - ); - - this.connectionId = this.lib.connectionOpen( - this.serverId, - this.outboundCallback, - null, - null, - 0, - null, - 0, - null, - 0 - ); - if (!this.connectionId) { - this.unregisterCallback(); - this.lib.hostShutdown(this.serverId); - this.serverId = 0; - throw new Error("copilot_runtime_connection_open failed."); + try { + this.outboundCallback = createPointer({ + paramsType: [this.lib.outboundCallbackType], + paramsValue: [ + (_userData: bigint, bytesAddress: bigint, bytesLen: number) => { + this.feedInbound(bytesAddress, bytesLen); + return 0; + }, + ], + }); + this.outboundCallbackPointer = unwrapPointer(this.outboundCallback)[0]; + this.inboundAddressOwner = createPointer({ + paramsType: [DataType.BigInt], + paramsValue: [0n], + }); + this.inboundAddressSlot = createExternalBuffer(this.inboundAddressOwner[0], 8); + + const empty = Buffer.alloc(0); + this.connectionId = this.lib.connectionOpen( + this.serverId, + this.outboundCallbackPointer, + this.outboundCallbackPointer, + empty, + 0, + empty, + 0, + empty, + 0 + ); + if (!this.connectionId) { + throw new Error("copilot_runtime_connection_open failed."); + } + } catch (error) { + this.releaseCallbackResources(); + this.shutdownHost(); + throw error; } - - // The in-process transport has no socket/pipe handle to keep the Node event loop - // alive while the SDK is idle awaiting a server→client frame. koffi delivers the - // outbound callback on the loop but does not reference it, so hold one referenced - // timer for the lifetime of the connection. - this.keepAliveTimer = setInterval(() => {}, KEEP_ALIVE_INTERVAL_MS); } finally { this.starting = false; if (this.disposed) { @@ -272,12 +349,11 @@ export class FfiRuntimeHost { } /** - * Native outbound (server→client) callback. koffi delivers it on the JS event loop - * via a threadsafe function, so the frame is decoded and written straight to - * {@link receiveStream}. The native pointer is only valid for this call, so the - * bytes are copied out before returning. + * Native outbound (server→client) callback. ffi-rs dispatches it through a blocking + * thread-safe function, so the native pointer remains valid until this callback + * returns. Copy the bytes before returning. */ - private feedInbound(bytesPtr: unknown, bytesLen: number | bigint): void { + private feedInbound(bytesAddress: bigint, bytesLen: number): void { // An exception thrown across the native→JS (Node-API) boundary cannot propagate // and would surface only as a DEP0168 "uncaught Node-API callback exception" // warning, so catch and log it here instead of letting it escape. @@ -289,14 +365,17 @@ export class FfiRuntimeHost { if (this.disposed || this.receiveStream.writableEnded) { return; } - const length = Number(bytesLen); - if (!bytesPtr || length <= 0) { + if (bytesAddress === 0n || bytesLen <= 0) { return; } - const bytes = koffi.decode( - bytesPtr, - koffi.array("uint8", length, "Typed") - ) as Uint8Array; + if (!this.inboundAddressOwner || !this.inboundAddressSlot) { + throw new Error("In-process FFI callback address storage is unavailable."); + } + this.inboundAddressSlot.writeBigInt64LE(bytesAddress); + const [bytes] = restorePointer({ + retType: [arrayConstructor({ type: DataType.U8Array, length: bytesLen })], + paramsValue: this.inboundAddressOwner, + }); this.receiveStream.write(Buffer.from(bytes)); } catch (error) { console.error( @@ -305,21 +384,63 @@ export class FfiRuntimeHost { } } - private unregisterCallback(): boolean { - if (this.outboundCallback === undefined) { - return true; + private releaseCallbackResources(): boolean { + let released = true; + if (this.outboundCallback !== undefined) { + const callback = this.outboundCallback; + try { + freePointer({ + paramsType: [this.lib.outboundCallbackType], + paramsValue: callback, + pointerType: PointerType.RsPointer, + }); + this.outboundCallback = undefined; + this.outboundCallbackPointer = undefined; + } catch (error) { + console.error( + `Failed to free in-process FFI callback: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}` + ); + released = false; + } + } + if (this.inboundAddressOwner !== undefined) { + const addressOwner = this.inboundAddressOwner; + try { + freePointer({ + paramsType: [DataType.BigInt], + paramsValue: addressOwner, + pointerType: PointerType.RsPointer, + }); + this.inboundAddressOwner = undefined; + this.inboundAddressSlot = undefined; + } catch (error) { + console.error( + `Failed to free in-process FFI callback address storage: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}` + ); + released = false; + } + } + if (!released) { + FfiRuntimeHost.quarantinedHosts.add(this); + } + return released; + } + + private shutdownHost(): void { + if (!this.serverId) { + return; } - const callback = this.outboundCallback; + const serverId = this.serverId; try { - koffi.unregister(callback); - this.outboundCallback = undefined; - return true; + if (!this.lib.hostShutdown(serverId)) { + console.error(`In-process FFI host shutdown did not recognize server ${serverId}.`); + } } catch (error) { console.error( - `Failed to unregister in-process FFI callback: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}` + `Failed to shut down in-process FFI host: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}` ); - FfiRuntimeHost.quarantinedHosts.add(this); - return false; + } finally { + this.serverId = 0; } } @@ -346,27 +467,12 @@ export class FfiRuntimeHost { try { // Close waits for outbound callbacks, which need the JS event loop // to run and return before native code can report quiescence. - closed = await new Promise((resolvePromise, rejectPromise) => { - this.lib.connectionClose.async( - this.connectionId, - (error: Error | null, result: boolean) => { - if (error) { - rejectPromise(error); - } else { - resolvePromise(result); - } - } - ); - }); + closed = await this.lib.connectionClose(this.connectionId); } catch (error) { console.error( `Failed to close in-process FFI connection: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}` ); - FfiRuntimeHost.quarantinedHosts.add(this); - if (this.keepAliveTimer !== undefined) { - clearInterval(this.keepAliveTimer); - this.keepAliveTimer = undefined; - } + this.scheduleCleanupRetry(); return; } if (!closed) { @@ -375,31 +481,10 @@ export class FfiRuntimeHost { } this.connectionId = 0; } - const callbackUnregistered = this.unregisterCallback(); - - // The referenced timer is part of the callback lifetime. Clearing it - // before connection_close reports quiescence can let the process exit - // while native code still owns the Koffi registration. - if (this.keepAliveTimer !== undefined) { - clearInterval(this.keepAliveTimer); - this.keepAliveTimer = undefined; - } + const callbackResourcesReleased = this.releaseCallbackResources(); - if (this.serverId) { - try { - if (!this.lib.hostShutdown(this.serverId)) { - console.error( - `In-process FFI host shutdown did not recognize server ${this.serverId}.` - ); - } - } catch (error) { - console.error( - `Failed to shut down in-process FFI host: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}` - ); - } - this.serverId = 0; - } - if (callbackUnregistered) { + this.shutdownHost(); + if (callbackResourcesReleased) { FfiRuntimeHost.quarantinedHosts.delete(this); } } finally { diff --git a/nodejs/test/ffiRuntimeHost.test.ts b/nodejs/test/ffiRuntimeHost.test.ts index e0fb3d71af..8eb2fceafd 100644 --- a/nodejs/test/ffiRuntimeHost.test.ts +++ b/nodejs/test/ffiRuntimeHost.test.ts @@ -3,46 +3,53 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const ffi = vi.hoisted(() => { let registeredCallback: - | ((userData: unknown, bytesPtr: unknown, bytesLen: number) => void) + | ((userData: bigint, bytesPtr: bigint, bytesLen: number) => number) | undefined; - const callbackToken = {}; - const hostStart = Object.assign(vi.fn(), { - async: vi.fn( - ( - _argv: Buffer, - _argvLength: number, - _env: Buffer | null, - _envLength: number, - callback: (error: Error | null, result: number) => void - ) => callback(null, 11) - ), - }); - const hostShutdown = vi.fn(() => true); - const connectionOpen = vi.fn(() => 21); - const connectionWrite = vi.fn(() => true); - const connectionClose = Object.assign(vi.fn<() => boolean>(), { - async: vi.fn< - (connectionId: number, callback: (error: Error | null, result: boolean) => void) => void - >(), - }); - const register = vi.fn( - (callback: (userData: unknown, bytesPtr: unknown, bytesLen: number) => void) => { - registeredCallback = callback; - return callbackToken; + const callbackToken = { owner: true }; + const callbackPointer = { pointer: true }; + const callbackOwner = [callbackToken]; + const addressOwner = [{ address: true }]; + const addressSlot = Buffer.alloc(8); + const bytesPointer = { bytes: true }; + const hostStart = vi.fn(async (..._args: unknown[]) => 11); + const hostShutdown = vi.fn((..._args: unknown[]) => true); + const connectionOpen = vi.fn((..._args: unknown[]) => 21); + const connectionWrite = vi.fn((..._args: unknown[]) => true); + const connectionClose = vi.fn(async (..._args: unknown[]) => true); + const createPointer = vi.fn( + ({ paramsValue: [value] }: { paramsType: unknown[]; paramsValue: [unknown] }) => { + if (typeof value === "function") { + registeredCallback = value as ( + userData: bigint, + bytesPtr: bigint, + bytesLen: number + ) => number; + return callbackOwner; + } + return addressOwner; } ); - const unregister = vi.fn(); + const freePointer = vi.fn(); + const createExternalBuffer = vi.fn(() => addressSlot); + const restorePointer = vi.fn(() => [Buffer.from([1])]); return { + addressOwner, + addressSlot, + bytesPointer, + callbackOwner, + callbackPointer, callbackToken, connectionClose, connectionOpen, + createExternalBuffer, + createPointer, + freePointer, connectionWrite, getRegisteredCallback: () => registeredCallback, hostShutdown, hostStart, - register, - unregister, + restorePointer, }; }); @@ -50,25 +57,35 @@ vi.mock("node:fs", () => ({ existsSync: vi.fn(() => true), })); -vi.mock("koffi", () => ({ - default: { - array: vi.fn(() => ({})), - decode: vi.fn(() => new Uint8Array([1])), - load: vi.fn(() => ({ - func: vi.fn((name: string) => { - if (name.endsWith("host_start")) return ffi.hostStart; - if (name.endsWith("host_shutdown")) return ffi.hostShutdown; - if (name.endsWith("connection_open")) return ffi.connectionOpen; - if (name.endsWith("connection_write")) return ffi.connectionWrite; - if (name.endsWith("connection_close")) return ffi.connectionClose; - throw new Error(`Unexpected FFI symbol: ${name}`); - }), - })), - pointer: vi.fn(() => ({})), - proto: vi.fn(() => ({})), - register: ffi.register, - unregister: ffi.unregister, +vi.mock("ffi-rs", () => ({ + DataType: { + BigInt: 16, + Boolean: 6, + External: 11, + U8Array: 10, + U32: 20, + U64: 12, + Void: 7, }, + PointerType: { RsPointer: 0 }, + arrayConstructor: vi.fn((options) => options), + createExternalBuffer: ffi.createExternalBuffer, + createPointer: ffi.createPointer, + freePointer: ffi.freePointer, + funcConstructor: vi.fn((options) => options), + load: vi.fn(({ funcName, paramsValue }: { funcName: string; paramsValue: unknown[] }) => { + if (funcName.endsWith("host_start")) return ffi.hostStart(...paramsValue); + if (funcName.endsWith("host_shutdown")) return ffi.hostShutdown(...paramsValue); + if (funcName.endsWith("connection_open")) return ffi.connectionOpen(...paramsValue); + if (funcName.endsWith("connection_write")) return ffi.connectionWrite(...paramsValue); + if (funcName.endsWith("connection_close")) return ffi.connectionClose(...paramsValue); + throw new Error(`Unexpected FFI symbol: ${funcName}`); + }), + open: vi.fn(), + restorePointer: ffi.restorePointer, + unwrapPointer: vi.fn((owner) => + owner === ffi.callbackOwner ? [ffi.callbackPointer] : [ffi.bytesPointer] + ), })); import { FfiRuntimeHost } from "../src/ffiRuntimeHost.js"; @@ -77,57 +94,58 @@ describe("FfiRuntimeHost callback cleanup", () => { beforeEach(() => { vi.useFakeTimers(); ffi.connectionClose.mockReset(); - ffi.connectionClose.async - .mockReset() - .mockImplementation((_id, callback) => callback(null, true)); + ffi.connectionClose.mockImplementation(async () => true); ffi.connectionOpen.mockClear(); + ffi.createExternalBuffer.mockReset(); + ffi.createExternalBuffer.mockReturnValue(ffi.addressSlot); ffi.hostShutdown.mockClear(); ffi.hostStart.mockClear(); - ffi.hostStart.async.mockClear(); - ffi.register.mockClear(); - ffi.unregister.mockClear(); + ffi.addressSlot.fill(0); + ffi.createPointer.mockClear(); + ffi.freePointer.mockClear(); + ffi.restorePointer.mockClear(); }); afterEach(() => { - expect(ffi.connectionClose).not.toHaveBeenCalled(); vi.clearAllTimers(); vi.useRealTimers(); }); it("finishes disposal after a false close while retaining resources for the detached retry", async () => { - ffi.connectionClose.async.mockImplementationOnce((_id, callback) => callback(null, false)); + ffi.connectionClose.mockResolvedValueOnce(false); const host = FfiRuntimeHost.create("runtime.node", undefined, undefined, []); await host.start(); await host.dispose(); - expect(ffi.connectionClose.async).toHaveBeenCalledTimes(1); - expect(ffi.unregister).not.toHaveBeenCalled(); + expect(ffi.connectionClose).toHaveBeenCalledTimes(1); + expect(ffi.freePointer).not.toHaveBeenCalled(); expect(ffi.hostShutdown).not.toHaveBeenCalled(); - expect((host as any).outboundCallback).toBe(ffi.callbackToken); - expect((host as any).keepAliveTimer).toBeDefined(); + expect((host as any).outboundCallback).toBe(ffi.callbackOwner); await vi.advanceTimersByTimeAsync(100); - expect(ffi.connectionClose.async).toHaveBeenCalledTimes(2); - expect(ffi.unregister).toHaveBeenCalledTimes(1); + expect(ffi.connectionClose).toHaveBeenCalledTimes(2); + expect(ffi.freePointer).toHaveBeenCalledTimes(2); expect(ffi.hostShutdown).toHaveBeenCalledTimes(1); expect((host as any).outboundCallback).toBeUndefined(); - expect((host as any).keepAliveTimer).toBeUndefined(); expect(vi.getTimerCount()).toBe(0); await host.dispose(); await vi.advanceTimersByTimeAsync(100); - expect(ffi.connectionClose.async).toHaveBeenCalledTimes(2); - expect(ffi.unregister).toHaveBeenCalledTimes(1); + expect(ffi.connectionClose).toHaveBeenCalledTimes(2); + expect(ffi.freePointer).toHaveBeenCalledTimes(2); expect(ffi.hostShutdown).toHaveBeenCalledTimes(1); }); it("waits for successful initial cleanup without overlapping close calls", async () => { let finishClose!: (error: Error | null, result: boolean) => void; - ffi.connectionClose.async.mockImplementationOnce((_id, callback) => { - finishClose = callback; - }); + ffi.connectionClose.mockImplementationOnce( + () => + new Promise((resolve) => { + finishClose = (_error, result) => resolve(result); + }) + ); const host = FfiRuntimeHost.create("runtime.node", undefined, undefined, []); await host.start(); @@ -139,71 +157,121 @@ describe("FfiRuntimeHost callback cleanup", () => { await vi.advanceTimersByTimeAsync(1000); expect(disposed).toBe(false); - expect(ffi.connectionClose.async).toHaveBeenCalledTimes(1); - expect(ffi.unregister).not.toHaveBeenCalled(); + expect(ffi.connectionClose).toHaveBeenCalledTimes(1); + expect(ffi.freePointer).not.toHaveBeenCalled(); expect(ffi.hostShutdown).not.toHaveBeenCalled(); - expect((host as any).outboundCallback).toBe(ffi.callbackToken); - expect((host as any).keepAliveTimer).toBeDefined(); + expect((host as any).outboundCallback).toBe(ffi.callbackOwner); finishClose(null, true); await cleanup; expect(disposed).toBe(true); - expect(ffi.unregister).toHaveBeenCalledTimes(1); + expect(ffi.freePointer).toHaveBeenCalledTimes(2); expect(ffi.hostShutdown).toHaveBeenCalledTimes(1); expect(vi.getTimerCount()).toBe(0); }); it("defers reclamation when dispose is called from the outbound callback", async () => { - ffi.connectionClose.async.mockImplementationOnce((_id, callback) => { - setImmediate(() => callback(null, true)); - }); + ffi.connectionClose.mockImplementationOnce( + () => new Promise((resolve) => setImmediate(() => resolve(true))) + ); const host = FfiRuntimeHost.create("runtime.node", undefined, undefined, []); await host.start(); host.receiveStream.once("data", () => { void host.dispose(); }); - ffi.getRegisteredCallback()?.(null, {}, 1); + const callbackResult = ffi.getRegisteredCallback()?.(0n, 1234n, 1); - expect(ffi.unregister).not.toHaveBeenCalled(); - expect((host as any).outboundCallback).toBe(ffi.callbackToken); + expect(callbackResult).toBe(0); + expect(ffi.createPointer.mock.calls[0][0].paramsType[0]).toEqual({ + paramsType: [16, 16, 12], + retType: 12, + }); + expect(ffi.createPointer.mock.calls[1][0].paramsType).toEqual([16]); + expect(ffi.freePointer).not.toHaveBeenCalled(); + expect((host as any).outboundCallback).toBe(ffi.callbackOwner); await vi.advanceTimersByTimeAsync(100); - expect(ffi.unregister).toHaveBeenCalledTimes(1); + expect(ffi.freePointer).toHaveBeenCalledTimes(2); + expect(ffi.freePointer.mock.calls[0][0].paramsValue).toBe(ffi.callbackOwner); + expect(ffi.freePointer.mock.calls[1][0].paramsValue).toBe(ffi.addressOwner); expect(ffi.hostShutdown).toHaveBeenCalledTimes(1); expect(vi.getTimerCount()).toBe(0); }); - it.each(["callback", "throw"])( - "quarantines the callback on a close %s error", + it("reconstructs callback pointers with the requested payload length", async () => { + const host = FfiRuntimeHost.create("runtime.node", undefined, undefined, []); + await host.start(); + + const callbackResult = ffi.getRegisteredCallback()?.(0n, 0x1234_5678n, 17); + + expect(callbackResult).toBe(0); + expect(ffi.addressSlot.readBigInt64LE()).toBe(0x1234_5678n); + expect(ffi.restorePointer).toHaveBeenCalledWith({ + retType: [{ type: 10, length: 17 }], + paramsValue: ffi.addressOwner, + }); + + await host.dispose(); + }); + + it.each(["callback storage", "connection open"])( + "rolls back native resources when %s setup fails", async (failure) => { - const closeError = new Error("close failed"); - ffi.connectionClose.async.mockImplementationOnce((_id, callback) => { - if (failure === "throw") { - throw closeError; - } - callback(closeError, false); - }); - const error = vi.spyOn(console, "error").mockImplementation(() => {}); + const setupError = new Error("setup failed"); + if (failure === "callback storage") { + ffi.createExternalBuffer.mockImplementationOnce(() => { + throw setupError; + }); + } else { + ffi.connectionOpen.mockImplementationOnce(() => { + throw setupError; + }); + } const host = FfiRuntimeHost.create("runtime.node", undefined, undefined, []); - await host.start(); - - await host.dispose(); - await vi.advanceTimersByTimeAsync(500); - - expect(ffi.connectionClose.async).toHaveBeenCalledTimes(1); - expect(ffi.unregister).not.toHaveBeenCalled(); - expect(ffi.hostShutdown).not.toHaveBeenCalled(); - expect((host as any).outboundCallback).toBe(ffi.callbackToken); - expect((FfiRuntimeHost as any).quarantinedHosts.has(host)).toBe(true); - expect(error).toHaveBeenCalledTimes(1); - expect(vi.getTimerCount()).toBe(0); - error.mockRestore(); + + await expect(host.start()).rejects.toBe(setupError); + + expect(ffi.freePointer).toHaveBeenCalledTimes(2); + expect(ffi.hostShutdown).toHaveBeenCalledWith(11); + expect((host as any).outboundCallback).toBeUndefined(); + expect((host as any).inboundAddressOwner).toBeUndefined(); + expect((host as any).serverId).toBe(0); } ); + it.each(["callback", "throw"])("retries cleanup after a close %s error", async (failure) => { + const closeError = new Error("close failed"); + ffi.connectionClose.mockImplementationOnce(() => { + if (failure === "throw") { + throw closeError; + } + return Promise.reject(closeError); + }); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + const host = FfiRuntimeHost.create("runtime.node", undefined, undefined, []); + await host.start(); + + await host.dispose(); + + expect(ffi.connectionClose).toHaveBeenCalledTimes(1); + expect(ffi.freePointer).not.toHaveBeenCalled(); + expect(ffi.hostShutdown).not.toHaveBeenCalled(); + expect((host as any).outboundCallback).toBe(ffi.callbackOwner); + expect(error).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(100); + + expect(ffi.connectionClose).toHaveBeenCalledTimes(2); + expect(ffi.freePointer).toHaveBeenCalledTimes(2); + expect(ffi.hostShutdown).toHaveBeenCalledTimes(1); + expect((host as any).outboundCallback).toBeUndefined(); + expect(vi.getTimerCount()).toBe(0); + error.mockRestore(); + }); + it("does not retry a terminal host shutdown failure", async () => { ffi.hostShutdown.mockReturnValueOnce(false); const error = vi.spyOn(console, "error").mockImplementation(() => {}); @@ -213,16 +281,16 @@ describe("FfiRuntimeHost callback cleanup", () => { await host.dispose(); await vi.advanceTimersByTimeAsync(500); - expect(ffi.connectionClose.async).toHaveBeenCalledTimes(1); - expect(ffi.unregister).toHaveBeenCalledTimes(1); + expect(ffi.connectionClose).toHaveBeenCalledTimes(1); + expect(ffi.freePointer).toHaveBeenCalledTimes(2); expect(ffi.hostShutdown).toHaveBeenCalledTimes(1); expect(vi.getTimerCount()).toBe(0); error.mockRestore(); }); - it("retains the callback token when Koffi unregistration fails", async () => { - ffi.unregister.mockImplementationOnce(() => { - throw new Error("unregister failed"); + it("retains the callback token when ffi-rs callback cleanup fails", async () => { + ffi.freePointer.mockImplementationOnce(() => { + throw new Error("free failed"); }); const error = vi.spyOn(console, "error").mockImplementation(() => {}); const host = FfiRuntimeHost.create("runtime.node", undefined, undefined, []); @@ -231,16 +299,15 @@ describe("FfiRuntimeHost callback cleanup", () => { await host.dispose(); await vi.advanceTimersByTimeAsync(500); - expect(ffi.connectionClose.async).toHaveBeenCalledTimes(1); - expect(ffi.unregister).toHaveBeenCalledTimes(1); + expect(ffi.connectionClose).toHaveBeenCalledTimes(1); + expect(ffi.freePointer).toHaveBeenCalledTimes(2); expect(ffi.hostShutdown).toHaveBeenCalledTimes(1); - expect((host as any).outboundCallback).toBe(ffi.callbackToken); + expect((host as any).outboundCallback).toBe(ffi.callbackOwner); expect((FfiRuntimeHost as any).quarantinedHosts.has(host)).toBe(true); - expect((host as any).keepAliveTimer).toBeUndefined(); expect(vi.getTimerCount()).toBe(0); await host.dispose(); - expect(ffi.unregister).toHaveBeenCalledTimes(1); + expect(ffi.freePointer).toHaveBeenCalledTimes(2); error.mockRestore(); }); }); From cf027561dcdc5393e815981e5aeded5c66e5fb18 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Sun, 13 Sep 2026 14:18:12 -0400 Subject: [PATCH 2/2] Fix ffi-rs non-void call safety Run every non-void ffi-rs call on its worker-thread path, preserve serialized writes, add regression coverage, and complete the samples lock graph. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- nodejs/samples/package-lock.json | 184 +++++++++++++++++++++++++++++ nodejs/src/ffiRuntimeHost.ts | 36 +++--- nodejs/test/ffiRuntimeHost.test.ts | 44 +++++-- 3 files changed, 238 insertions(+), 26 deletions(-) diff --git a/nodejs/samples/package-lock.json b/nodejs/samples/package-lock.json index f12e9cda18..927a856639 100644 --- a/nodejs/samples/package-lock.json +++ b/nodejs/samples/package-lock.json @@ -687,6 +687,172 @@ "url": "https://opencollective.com/vitest" } }, + "../node_modules/@yuuang/ffi-rs-android-arm64": { + "version": "1.3.7", + "integrity": "sha512-t6Wx3Xll6c07Nuk0k3xnZsxKFxlshm92i0U/BiTHc6kQbvu+fMJF+gKsj4yEj886jH51CM3EqZT9Xdhq9CdUVw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12" + } + }, + "../node_modules/@yuuang/ffi-rs-darwin-arm64": { + "version": "1.3.7", + "integrity": "sha512-OueBlUFBT9IwD9pQnoYs0UszRBEySskfrEPXXfvKfGjL/DXnfn6kUheQ3oIP6sSmshVGNQUwrTCPo6feAa4QjA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12" + } + }, + "../node_modules/@yuuang/ffi-rs-darwin-x64": { + "version": "1.3.7", + "integrity": "sha512-x4mxXOKwSgwYx6OBAKrW+Ocp8um2KoPpUwaD0Z2tmR6EekoRvFGN7s/eEK1avqCxcsLN7/MzGOnCCFyGpOVfPA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12" + } + }, + "../node_modules/@yuuang/ffi-rs-linux-arm-gnueabihf": { + "version": "1.3.7", + "integrity": "sha512-9r//Z022QYVoIAY458Dpk8HWkisk/y4NJC3d52RlkdsLD+9p0zn0cKKSoMF/xUsOWDFSmOf+cwU/Xadi/wxFMQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12" + } + }, + "../node_modules/@yuuang/ffi-rs-linux-arm64-gnu": { + "version": "1.3.7", + "integrity": "sha512-nrW4MlFlyQInfxTsI5wtcIpbV4KlHLWo2BCk4CHIETlUT+PUspNFqDg+Byy7My3hpCZlXp0WsMVP/3N0LlybNw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12" + } + }, + "../node_modules/@yuuang/ffi-rs-linux-arm64-musl": { + "version": "1.3.7", + "integrity": "sha512-eLLx4P8DzNnuPif19nAvKPaf88lSf6KFbDetH3nAIRZvI495h0jigN835Ayr7uAADssohGfO9MPMH33DXmFErA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12" + } + }, + "../node_modules/@yuuang/ffi-rs-linux-x64-gnu": { + "version": "1.3.7", + "integrity": "sha512-upEz1Q98T51x2In872fsjgHCJbE3e8r9JxhJD+5NvSMk6BpieYWRodTbsMxV6oKWmfl12loKwF2j4OBSWKl0lA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12" + } + }, + "../node_modules/@yuuang/ffi-rs-linux-x64-musl": { + "version": "1.3.7", + "integrity": "sha512-YOcOkIwVvIhpwuGJ428h15vpE0KnT3teFG0g2J31FrBxrVh9IH4HeTGXjelI3l9SI5gyiZDnD4PQnQ1U6oqTcA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12" + } + }, + "../node_modules/@yuuang/ffi-rs-win32-arm64-msvc": { + "version": "1.3.7", + "integrity": "sha512-ZfYzLV1w7Mhzh9XRScEMpw9oazIEiwO+0+TYHHRL882d5oD0Q99YikmRhdifsoHCCQefKmWF/4BXCeFkzg8NFA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12" + } + }, + "../node_modules/@yuuang/ffi-rs-win32-ia32-msvc": { + "version": "1.3.7", + "integrity": "sha512-ifYz+f+giKpT39lKWDru+om6QnA9J7J953ns60xHgB46ESZvPd/Y2W5+PesRNC9U+vnFEiLZ9CNC+R3fBWC1pg==", + "cpu": [ + "x64", + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12" + } + }, + "../node_modules/@yuuang/ffi-rs-win32-x64-msvc": { + "version": "1.3.7", + "integrity": "sha512-H3s4wOLtZtKtmuPjUNE+bhnSKxxpXcuxG70jRC8dUMHqIm5iCvdoZ4XYDeq7/uUYlySerXFmRL4dAVCX/TUNTg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12" + } + }, "../node_modules/abort-controller": { "version": "3.0.0", "dev": true, @@ -1238,6 +1404,24 @@ } } }, + "../node_modules/ffi-rs": { + "version": "1.3.7", + "integrity": "sha512-5MMZQS2t/6f/ec3sx0f51gBcKGZPGfolPAW04lvLOSYnr7gmOKs8YzH1Rh+34WGO+VtcgcTuxaUUoJLcR4Tirg==", + "license": "MIT", + "optionalDependencies": { + "@yuuang/ffi-rs-android-arm64": "1.3.7", + "@yuuang/ffi-rs-darwin-arm64": "1.3.7", + "@yuuang/ffi-rs-darwin-x64": "1.3.7", + "@yuuang/ffi-rs-linux-arm-gnueabihf": "1.3.7", + "@yuuang/ffi-rs-linux-arm64-gnu": "1.3.7", + "@yuuang/ffi-rs-linux-arm64-musl": "1.3.7", + "@yuuang/ffi-rs-linux-x64-gnu": "1.3.7", + "@yuuang/ffi-rs-linux-x64-musl": "1.3.7", + "@yuuang/ffi-rs-win32-arm64-msvc": "1.3.7", + "@yuuang/ffi-rs-win32-ia32-msvc": "1.3.7", + "@yuuang/ffi-rs-win32-x64-msvc": "1.3.7" + } + }, "../node_modules/file-entry-cache": { "version": "8.0.0", "dev": true, diff --git a/nodejs/src/ffiRuntimeHost.ts b/nodejs/src/ffiRuntimeHost.ts index a066de22f5..dd006141cb 100644 --- a/nodejs/src/ffiRuntimeHost.ts +++ b/nodejs/src/ffiRuntimeHost.ts @@ -45,7 +45,7 @@ interface FfiLibrary { environment: Buffer, environmentLength: number ): Promise; - hostShutdown(serverId: number): boolean; + hostShutdown(serverId: number): Promise; connectionOpen( serverId: number, callback: JsExternal, @@ -56,8 +56,8 @@ interface FfiLibrary { authLength: number, additional: Buffer, additionalLength: number - ): number; - connectionWrite(connectionId: number, frame: Buffer, frameLength: number): boolean; + ): Promise; + connectionWrite(connectionId: number, frame: Buffer, frameLength: number): Promise; connectionClose(connectionId: number): Promise; outboundCallbackType: FuncConstructorOptions; } @@ -110,6 +110,7 @@ function loadLibrary(libraryPath: string): FfiLibrary { retType: DataType.Boolean, paramsType: [DataType.U32], paramsValue: [serverId], + runInNewThread: true, }), connectionOpen: ( serverId, @@ -148,6 +149,7 @@ function loadLibrary(libraryPath: string): FfiLibrary { additional, additionalLength, ], + runInNewThread: true, }), connectionWrite: (connectionId, frame, frameLength) => load({ @@ -156,6 +158,7 @@ function loadLibrary(libraryPath: string): FfiLibrary { retType: DataType.Boolean, paramsType: [DataType.U32, DataType.U8Array, DataType.U64], paramsValue: [connectionId, frame, frameLength], + runInNewThread: true, }), connectionClose: (connectionId) => load({ @@ -227,15 +230,12 @@ export class FfiRuntimeHost { this.lib = loadLibrary(libraryPath); this.receiveStream = new PassThrough(); this.sendStream = new Writable({ - // connection_write enqueues the frame into the runtime's inbound channel and - // returns immediately, so a synchronous FFI call is sufficient here. write: (chunk: Buffer, _encoding, callback) => { - try { - this.writeFrame(chunk); - callback(); - } catch (error) { - callback(error as Error); - } + void this.writeFrame(chunk).then( + () => callback(), + (error: unknown) => + callback(error instanceof Error ? error : new Error(String(error))) + ); }, }); } @@ -311,7 +311,7 @@ export class FfiRuntimeHost { this.inboundAddressSlot = createExternalBuffer(this.inboundAddressOwner[0], 8); const empty = Buffer.alloc(0); - this.connectionId = this.lib.connectionOpen( + this.connectionId = await this.lib.connectionOpen( this.serverId, this.outboundCallbackPointer, this.outboundCallbackPointer, @@ -327,7 +327,7 @@ export class FfiRuntimeHost { } } catch (error) { this.releaseCallbackResources(); - this.shutdownHost(); + await this.shutdownHost(); throw error; } } finally { @@ -338,11 +338,11 @@ export class FfiRuntimeHost { } } - private writeFrame(frame: Buffer): void { + private async writeFrame(frame: Buffer): Promise { if (this.disposed || !this.connectionId) { throw new Error("The in-process runtime connection is closed."); } - const ok = this.lib.connectionWrite(this.connectionId, frame, frame.length); + const ok = await this.lib.connectionWrite(this.connectionId, frame, frame.length); if (!ok) { throw new Error("Failed to write a frame to the in-process runtime connection."); } @@ -426,13 +426,13 @@ export class FfiRuntimeHost { return released; } - private shutdownHost(): void { + private async shutdownHost(): Promise { if (!this.serverId) { return; } const serverId = this.serverId; try { - if (!this.lib.hostShutdown(serverId)) { + if (!(await this.lib.hostShutdown(serverId))) { console.error(`In-process FFI host shutdown did not recognize server ${serverId}.`); } } catch (error) { @@ -483,7 +483,7 @@ export class FfiRuntimeHost { } const callbackResourcesReleased = this.releaseCallbackResources(); - this.shutdownHost(); + await this.shutdownHost(); if (callbackResourcesReleased) { FfiRuntimeHost.quarantinedHosts.delete(this); } diff --git a/nodejs/test/ffiRuntimeHost.test.ts b/nodejs/test/ffiRuntimeHost.test.ts index 8eb2fceafd..c21e62c08f 100644 --- a/nodejs/test/ffiRuntimeHost.test.ts +++ b/nodejs/test/ffiRuntimeHost.test.ts @@ -32,6 +32,23 @@ const ffi = vi.hoisted(() => { const freePointer = vi.fn(); const createExternalBuffer = vi.fn(() => addressSlot); const restorePointer = vi.fn(() => [Buffer.from([1])]); + const load = vi.fn( + ({ + funcName, + paramsValue, + }: { + funcName: string; + paramsValue: unknown[]; + runInNewThread?: boolean; + }) => { + if (funcName.endsWith("host_start")) return hostStart(...paramsValue); + if (funcName.endsWith("host_shutdown")) return hostShutdown(...paramsValue); + if (funcName.endsWith("connection_open")) return connectionOpen(...paramsValue); + if (funcName.endsWith("connection_write")) return connectionWrite(...paramsValue); + if (funcName.endsWith("connection_close")) return connectionClose(...paramsValue); + throw new Error(`Unexpected FFI symbol: ${funcName}`); + } + ); return { addressOwner, @@ -49,6 +66,7 @@ const ffi = vi.hoisted(() => { getRegisteredCallback: () => registeredCallback, hostShutdown, hostStart, + load, restorePointer, }; }); @@ -73,14 +91,7 @@ vi.mock("ffi-rs", () => ({ createPointer: ffi.createPointer, freePointer: ffi.freePointer, funcConstructor: vi.fn((options) => options), - load: vi.fn(({ funcName, paramsValue }: { funcName: string; paramsValue: unknown[] }) => { - if (funcName.endsWith("host_start")) return ffi.hostStart(...paramsValue); - if (funcName.endsWith("host_shutdown")) return ffi.hostShutdown(...paramsValue); - if (funcName.endsWith("connection_open")) return ffi.connectionOpen(...paramsValue); - if (funcName.endsWith("connection_write")) return ffi.connectionWrite(...paramsValue); - if (funcName.endsWith("connection_close")) return ffi.connectionClose(...paramsValue); - throw new Error(`Unexpected FFI symbol: ${funcName}`); - }), + load: ffi.load, open: vi.fn(), restorePointer: ffi.restorePointer, unwrapPointer: vi.fn((owner) => @@ -100,6 +111,7 @@ describe("FfiRuntimeHost callback cleanup", () => { ffi.createExternalBuffer.mockReturnValue(ffi.addressSlot); ffi.hostShutdown.mockClear(); ffi.hostStart.mockClear(); + ffi.load.mockClear(); ffi.addressSlot.fill(0); ffi.createPointer.mockClear(); ffi.freePointer.mockClear(); @@ -217,6 +229,22 @@ describe("FfiRuntimeHost callback cleanup", () => { await host.dispose(); }); + it("runs all non-void ffi-rs calls in a worker thread", async () => { + const host = FfiRuntimeHost.create("runtime.node", undefined, undefined, []); + await host.start(); + await new Promise((resolve, reject) => { + host.sendStream.write(Buffer.from("frame"), (error) => + error ? reject(error) : resolve() + ); + }); + await host.dispose(); + + expect(ffi.load).toHaveBeenCalledTimes(5); + for (const [params] of ffi.load.mock.calls) { + expect(params.runInNewThread).toBe(true); + } + }); + it.each(["callback storage", "connection open"])( "rolls back native resources when %s setup fails", async (failure) => {