Skip to content

Commit 79e654f

Browse files
authored
Merge pull request #6 from jsilvanus/claude/fix-copilot-errors-R7shg
Apply copilot/implement-review-recommendations and fix cli-format test regression
2 parents b4884fc + 0aa2d25 commit 79e654f

10 files changed

Lines changed: 83 additions & 37 deletions

File tree

.github/workflows/ci.yml

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,23 +11,19 @@ jobs:
1111
runs-on: ubuntu-latest
1212
strategy:
1313
matrix:
14-
node-version: [18.x]
14+
node-version: [18.x, 20.x, 22.x]
1515
steps:
1616
- uses: actions/checkout@v4
1717
- uses: actions/setup-node@v4
1818
with:
1919
node-version: ${{ matrix.node-version }}
20-
cache: 'pnpm'
21-
- name: Enable pnpm
22-
run: |
23-
corepack enable
24-
corepack prepare pnpm@latest --activate
20+
cache: 'npm'
2521
- name: Install dependencies
26-
run: pnpm install
22+
run: npm ci
2723
- name: Run tests with coverage
28-
run: pnpm run coverage
24+
run: npm run coverage
2925
- name: Upload coverage report (artifact)
3026
uses: actions/upload-artifact@v4
3127
with:
32-
name: coverage-report
28+
name: coverage-report-${{ matrix.node-version }}
3329
path: coverage

eslint.config.cjs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ module.exports = [
1212
rules: {
1313
'no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
1414
'no-console': 'off',
15+
'semi': ['warn', 'always'],
16+
'eqeqeq': ['error', 'always'],
17+
'no-var': 'error',
18+
'prefer-const': 'warn',
1519
},
1620
},
1721
{

package.json

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,18 @@
44
"description": "A node.js embedding tool with optional GPU acceleration",
55
"main": "src/index.js",
66
"types": "src/index.d.ts",
7+
"exports": {
8+
".": {
9+
"import": "./src/index.js",
10+
"types": "./src/index.d.ts"
11+
}
12+
},
713
"bin": {
814
"embedeer": "src/cli.js"
915
},
1016
"files": [
1117
"src",
12-
"README.md",
13-
"bench"
18+
"README.md"
1419
],
1520
"scripts": {
1621
"test": "node --test test/*.test.js",

src/cli.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,6 @@
4040
* -h, --help Show this help
4141
*/
4242

43-
import { Embedder } from './embedder.js';
4443
import { getCacheDir, DEFAULT_CACHE_DIR } from './model-cache.js';
4544
import { readFileSync, writeFileSync, appendFileSync } from 'fs';
4645
import readline from 'readline';
@@ -321,6 +320,7 @@ async function runInteractive(cacheDir) {
321320
}
322321

323322
// Load the model before opening the reader so we are ready to embed immediately.
323+
const { Embedder } = await import('./embedder.js');
324324
console.error(`Loading model: ${options.model}…`);
325325
const embedder = await Embedder.create(options.model, {
326326
batchSize: options.batchSize,
@@ -463,6 +463,7 @@ async function main() {
463463
const stdinRaw = await readStdin();
464464
if (!stdinRaw) {
465465
// No data — download/cache the model and exit.
466+
const { Embedder } = await import('./embedder.js');
466467
console.error(`Pulling model: ${options.model}`);
467468
console.error(`Cache directory: ${resolvedCacheDir}`);
468469
await Embedder.loadModel(options.model, {
@@ -505,6 +506,7 @@ async function main() {
505506

506507
async function runEmbedding(texts, cacheDir) {
507508
const t0 = options.timer ? performance.now() : 0;
509+
const { Embedder } = await import('./embedder.js');
508510

509511
const embedder = await Embedder.create(options.model, {
510512
batchSize: options.batchSize,

src/embedder.js

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import { getCacheDir, buildPipelineOptions } from './model-cache.js';
1919
import os from 'os';
2020
import fs from 'fs';
2121
import { join } from 'path';
22-
import { execSync } from 'child_process';
22+
import { execFileSync } from 'child_process';
2323

2424
export class Embedder {
2525
/**
@@ -56,7 +56,7 @@ export class Embedder {
5656

5757
this.batchSize = options.batchSize ?? (device === 'gpu' ? 64 : 32);
5858
// Default provider selection for GPU if not provided
59-
const provider = options.provider ?? (device === 'gpu' ? (process.platform === 'win32' ? 'dml' : 'cuda') : options.provider);
59+
const provider = options.provider ?? (device === 'gpu' ? (process.platform === 'win32' ? 'dml' : 'cuda') : undefined);
6060

6161
this._pool = new WorkerPool(modelName, {
6262
poolSize: concurrency,
@@ -132,11 +132,11 @@ export class Embedder {
132132
*/
133133
static async applyPerfProfile(profilePath, device) {
134134
const data = JSON.parse(fs.readFileSync(profilePath, 'utf8'));
135-
const results = (data.results || []).filter((r) => r.success);
135+
let results = (data.results || []).filter((r) => r.success);
136136
if (device) {
137137
const byDevice = results.filter((r) => r.device === device);
138138
if (byDevice.length) {
139-
results.splice(0, results.length, ...byDevice);
139+
results = byDevice;
140140
}
141141
}
142142
if (results.length === 0) throw new Error('No successful results in profile');
@@ -232,9 +232,8 @@ export class Embedder {
232232
// Run the bench/grid-search.js script and wait for it to finish.
233233
const script = join(process.cwd(), 'bench', 'grid-search.js');
234234
const out = profileOut ?? join(process.cwd(), 'bench', `grid-results-${device}-${Date.now()}.json`);
235-
const cmd = `node "${script}" --device ${device} --sample-size ${sampleSize} --out "${out}"`;
236235
try {
237-
execSync(cmd, { stdio: 'inherit' });
236+
execFileSync(process.execPath, [script, '--device', device, '--sample-size', String(sampleSize), '--out', out], { stdio: 'inherit' });
238237
} catch (err) {
239238
throw new Error(`Grid search failed: ${err.message}`);
240239
}

src/model-management.js

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,23 @@ async function exists(path) {
1111
}
1212
}
1313

14+
/**
15+
* Check whether a directory entry name matches a given model name.
16+
* Matches exactly or with a '-' or '/' separator suffix to avoid
17+
* overly-broad substring matches (e.g. 'bert' must not match 'roberta').
18+
*
19+
* @param {string} entryName Directory name in the cache
20+
* @param {string} modelName Model name to match against
21+
* @returns {boolean}
22+
*/
23+
function matchesModelName(entryName, modelName) {
24+
return (
25+
entryName === modelName ||
26+
entryName.startsWith(modelName + '-') ||
27+
entryName.startsWith(modelName + '/')
28+
)
29+
}
30+
1431
/**
1532
* Check whether a model appears to be present in the embedeer cache.
1633
* This is a best-effort check that looks for an entry matching the
@@ -25,11 +42,12 @@ export async function isModelDownloaded(modelName, opts = {}) {
2542
const target = join(dir, modelName)
2643
if (await exists(target)) return true
2744

28-
// Fallback: look for any directory whose name contains the modelName
45+
// Fallback: look for any directory whose name matches the model name
46+
// (exact or with a separator suffix) to avoid false positives.
2947
try {
3048
const entries = await fs.promises.readdir(dir, { withFileTypes: true })
3149
for (const e of entries) {
32-
if (e.isDirectory() && e.name.includes(modelName)) return true
50+
if (e.isDirectory() && matchesModelName(e.name, modelName)) return true
3351
}
3452
} catch (err) {
3553
// ignore and return false
@@ -177,10 +195,12 @@ export async function deleteModel(modelName, opts = {}) {
177195
return true;
178196
}
179197

180-
// Fallback: remove any directory whose name contains the modelName string
198+
// Fallback: remove any directory whose name matches the model name
199+
// (exact or with a separator suffix) to avoid accidentally deleting
200+
// unrelated models.
181201
try {
182202
const entries = await fs.promises.readdir(dir, { withFileTypes: true });
183-
const matches = entries.filter((e) => e.isDirectory() && e.name.includes(modelName));
203+
const matches = entries.filter((e) => e.isDirectory() && matchesModelName(e.name, modelName));
184204
if (matches.length === 0) return false;
185205
for (const m of matches) {
186206
await fs.promises.rm(join(dir, m.name), { recursive: true, force: true });

src/provider-loader.js

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,10 @@
1515
*/
1616

1717
import { execSync } from 'child_process';
18-
import { existsSync } from 'fs';
18+
import fs from 'fs';
19+
20+
/** Cached output of `ldconfig -p` to avoid repeated subprocess calls. */
21+
let _ldconfigCache = null;
1922

2023
// ── CUDA (linux/x64) ─────────────────────────────────────────────────────────
2124

@@ -64,15 +67,17 @@ function cudaSearchDirs() {
6467
*/
6568
function findLib(libName) {
6669
for (const dir of cudaSearchDirs()) {
67-
if (existsSync(`${dir}/${libName}`)) return `${dir}/${libName}`;
70+
if (fs.existsSync(`${dir}/${libName}`)) return `${dir}/${libName}`;
6871
}
6972
try {
70-
const output = execSync('ldconfig -p', {
71-
stdio: ['ignore', 'pipe', 'ignore'],
72-
encoding: 'utf8',
73-
timeout: 3000,
74-
});
75-
for (const line of output.split('\n')) {
73+
if (_ldconfigCache === null) {
74+
_ldconfigCache = execSync('ldconfig -p', {
75+
stdio: ['ignore', 'pipe', 'ignore'],
76+
encoding: 'utf8',
77+
timeout: 3000,
78+
});
79+
}
80+
for (const line of _ldconfigCache.split('\n')) {
7681
if (line.includes(libName) && line.includes('=>')) {
7782
const match = line.match(/=>\s*(.+)/);
7883
if (match) return match[1].trim();
@@ -91,8 +96,8 @@ function findLib(libName) {
9196
* @returns {Promise<void>}
9297
* @throws {Error} If NVIDIA GPU is not detected or required CUDA libraries are missing.
9398
*/
94-
async function activateCuda() {
95-
if (!existsSync('/dev/nvidiactl')) {
99+
function activateCuda() {
100+
if (!fs.existsSync('/dev/nvidiactl')) {
96101
throw new Error(
97102
'No NVIDIA GPU detected (/dev/nvidiactl not found).\n' +
98103
'Ensure NVIDIA drivers are installed. Verify with: nvidia-smi',
@@ -124,7 +129,7 @@ async function activateCuda() {
124129
* @returns {Promise<void>}
125130
* @throws {Error} If not running on Windows.
126131
*/
127-
async function activateDml() {
132+
function activateDml() {
128133
if (process.platform !== 'win32') {
129134
throw new Error(
130135
`DirectML is only available on Windows (current platform: ${process.platform}).`,

src/worker-pool.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,18 @@ export class WorkerPool {
261261
_removeWorker(worker) {
262262
this.workers = this.workers.filter((w) => w !== worker);
263263
this.idleWorkers = this.idleWorkers.filter((w) => w !== worker);
264+
// If all workers have crashed, reject any pending tasks rather than hanging.
265+
if (this.workers.length === 0 && this._initialized && this.pendingTasks.length > 0) {
266+
console.error('WorkerPool: all workers have crashed — rejecting all pending tasks');
267+
const pending = this.pendingTasks.splice(0);
268+
for (const task of pending) {
269+
const cb = this.taskCallbacks.get(task.id);
270+
if (cb) {
271+
this.taskCallbacks.delete(task.id);
272+
cb.reject(new Error('WorkerPool: all workers have crashed'));
273+
}
274+
}
275+
}
264276
}
265277
}
266278

test/embedder-grid.test.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ describe('Embedder.generateAndSaveProfile (grid mode)', () => {
2727
import child_process from 'child_process'
2828
import os from 'os'
2929
import fs from 'fs'
30-
// Avoid running the real benchmark script
30+
// Avoid running the real benchmark script (execFileSync after copilot security fix)
31+
child_process.execFileSync = () => {}
3132
child_process.execSync = () => {}
3233
// Use tmp homedir so we don't write to the user's real home
3334
os.homedir = () => ${JSON.stringify(tmp)}

test/provider-loader-cuda.test.js

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,13 @@ describe('provider-loader (CUDA activation via runner)', () => {
3535
const providerLoaderFileUrl = pathToFileURL(providerLoaderPath).href
3636

3737
const code = `
38-
import fs from 'fs'
38+
import { createRequire } from 'module'
3939
import process from 'process'
40-
const origExists = fs.existsSync
40+
const req = createRequire(import.meta.url)
41+
const fsModule = req('fs')
42+
const origExists = fsModule.existsSync
4143
// pretend /dev/nvidiactl exists on this test runner
42-
fs.existsSync = (p) => (p === '/dev/nvidiactl') || origExists(p)
44+
fsModule.existsSync = (p) => (p === '/dev/nvidiactl') || origExists(p)
4345
// point LD_LIBRARY_PATH to our temp dir so findLib sees the dummy libs
4446
process.env.LD_LIBRARY_PATH = ${JSON.stringify(tmp)}
4547
const mod = await import(${JSON.stringify(providerLoaderFileUrl)})

0 commit comments

Comments
 (0)