Skip to content

Commit 3d5e05f

Browse files
author
William
committed
fix: use dedicated embedding context lifecycle
1 parent 142608b commit 3d5e05f

9 files changed

Lines changed: 250 additions & 143 deletions

File tree

.github/workflows/release.yml

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -22,20 +22,14 @@ jobs:
2222
node-version: 20
2323
registry-url: https://registry.npmjs.org
2424

25-
- name: Validate tag matches package version
26-
run: |
27-
PKG_VERSION=$(node -p "require('./package.json').version")
28-
TAG_VERSION="${GITHUB_REF_NAME#v}"
29-
if [ "$PKG_VERSION" != "$TAG_VERSION" ]; then
30-
echo "Tag version ($TAG_VERSION) does not match package.json version ($PKG_VERSION)"
31-
exit 1
32-
fi
25+
- name: Validate release
26+
run: npm run verify:release -- "$GITHUB_REF_NAME" --check-registry
3327

3428
- name: Install dependencies
3529
run: npm ci
3630

37-
- name: Build package
38-
run: npm run build
31+
- name: Test package
32+
run: npm test && npm run typecheck && npm audit
3933

4034
- name: Pack package
4135
run: npm pack --dry-run

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# Changelog
2+
3+
## [0.3.0] - 2026-07-14
4+
5+
### Changed
6+
7+
- Added a dedicated, branded embedding-context handle and required
8+
`freeEmbeddingContext` native destructor. Native adapters must implement the
9+
updated contract before upgrading.
10+
- Reject embedding operations after disposal before tokenization begins.
11+
12+
### Security
13+
14+
- Updated locked build dependencies to patched versions.
15+
16+
[0.3.0]: https://github.com/hilum-labs/local-llm-js-core/releases/tag/v0.3.0

package-lock.json

Lines changed: 112 additions & 112 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "local-llm-js-core",
3-
"version": "0.2.1",
3+
"version": "0.3.0",
44
"description": "Shared core runtime and native contract for local-llm packages",
55
"type": "module",
66
"main": "dist/index.js",
@@ -20,11 +20,14 @@
2020
"files": [
2121
"dist",
2222
"README.md",
23+
"CHANGELOG.md",
2324
"LICENSE"
2425
],
2526
"scripts": {
2627
"build": "tsup src/index.ts src/native.ts --format esm --dts",
28+
"test": "npm run build && node --test tests/*.test.mjs",
2729
"typecheck": "tsc --noEmit",
30+
"verify:release": "node scripts/validate-release.mjs",
2831
"release": "node scripts/release.mjs"
2932
},
3033
"keywords": [
@@ -47,5 +50,8 @@
4750
"devDependencies": {
4851
"tsup": "^8.0.0",
4952
"typescript": "^5.4.0"
53+
},
54+
"overrides": {
55+
"esbuild": "^0.28.1"
5056
}
5157
}

scripts/release.mjs

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import { createInterface } from 'node:readline/promises';
44
import { stdin as input, stdout as output } from 'node:process';
55
import { spawnSync } from 'node:child_process';
6-
import { readFileSync, writeFileSync } from 'node:fs';
6+
import { readFileSync } from 'node:fs';
77
import path from 'node:path';
88
import { fileURLToPath } from 'node:url';
99

@@ -29,12 +29,23 @@ function run(cmd, args, opts = {}) {
2929
return (res.stdout || '').trim();
3030
}
3131

32-
function readPackageJson() {
33-
return JSON.parse(readFileSync(packageJsonPath, 'utf8'));
32+
function ensureVersionAvailableOnNpm(pkgName, version) {
33+
const result = spawnSync('npm', ['view', `${pkgName}@${version}`, 'version'], {
34+
cwd: repoRoot,
35+
stdio: ['ignore', 'pipe', 'pipe'],
36+
encoding: 'utf8',
37+
});
38+
if (result.status === 0) throw new Error(`${pkgName}@${version} is already published on npm.`);
39+
40+
const output = `${result.stdout || ''}\n${result.stderr || ''}`;
41+
if (!/E404|404 Not Found/i.test(output)) {
42+
if (output.trim()) console.error(output.trim());
43+
throw new Error(`Could not verify npm availability for ${pkgName}@${version}.`);
44+
}
3445
}
3546

36-
function writePackageJson(pkg) {
37-
writeFileSync(packageJsonPath, `${JSON.stringify(pkg, null, 2)}\n`, 'utf8');
47+
function readPackageJson() {
48+
return JSON.parse(readFileSync(packageJsonPath, 'utf8'));
3849
}
3950

4051
function parseSemver(v) {
@@ -74,14 +85,16 @@ async function main() {
7485

7586
console.log(`Current version: ${current}`);
7687
console.log('Select release type:');
88+
console.log('0) release current prepared version');
7789
console.log('1) patch');
7890
console.log('2) minor');
7991
console.log('3) major');
8092
console.log('4) custom');
8193

8294
const choice = (await rl.question('Choice [1]: ')).trim() || '1';
8395
let nextVersion;
84-
if (choice === '1') nextVersion = bump(current, 'patch');
96+
if (choice === '0') nextVersion = current;
97+
else if (choice === '1') nextVersion = bump(current, 'patch');
8598
else if (choice === '2') nextVersion = bump(current, 'minor');
8699
else if (choice === '3') nextVersion = bump(current, 'major');
87100
else if (choice === '4') {
@@ -104,12 +117,23 @@ async function main() {
104117
ensureCleanGit();
105118
ensureMainBranch();
106119
run('git', ['pull', '--rebase']);
120+
ensureVersionAvailableOnNpm(pkg.name, nextVersion);
107121

108-
pkg.version = nextVersion;
109-
writePackageJson(pkg);
122+
if (nextVersion !== current) {
123+
run('npm', ['version', nextVersion, '--no-git-tag-version']);
124+
}
125+
126+
run('npm', ['ci']);
127+
run('npm', ['test']);
128+
run('npm', ['run', 'typecheck']);
129+
run('npm', ['audit']);
130+
run('npm', ['pack', '--dry-run']);
131+
run('npm', ['run', 'verify:release', '--', tag]);
110132

111-
run('git', ['add', 'package.json', '.github/workflows/release.yml', 'scripts/release.mjs']);
112-
run('git', ['commit', '-m', `chore(release): v${nextVersion}`]);
133+
if (nextVersion !== current) {
134+
run('git', ['add', 'package.json', 'package-lock.json']);
135+
run('git', ['commit', '-m', `chore(release): v${nextVersion}`]);
136+
}
113137
run('git', ['tag', '-a', tag, '-m', tag]);
114138
run('git', ['push', 'origin', 'HEAD']);
115139
run('git', ['push', 'origin', tag]);

scripts/validate-release.mjs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { readFileSync } from 'node:fs';
2+
import { spawnSync } from 'node:child_process';
3+
4+
const args = process.argv.slice(2).filter((arg) => arg !== '--');
5+
const tag = args.find((arg) => arg !== '--check-registry') ?? process.env.GITHUB_REF_NAME;
6+
const checkRegistry = args.includes('--check-registry');
7+
if (!tag?.startsWith('v') || !/^\d+\.\d+\.\d+$/.test(tag.slice(1))) {
8+
throw new Error(`expected a v-prefixed semantic version tag; received ${JSON.stringify(tag)}`);
9+
}
10+
11+
const manifest = JSON.parse(readFileSync('package.json', 'utf8'));
12+
const version = tag.slice(1);
13+
if (manifest.version !== version) {
14+
throw new Error(`package.json has version ${manifest.version}; expected ${version} from tag ${tag}`);
15+
}
16+
17+
if (checkRegistry) {
18+
const spec = `${manifest.name}@${version}`;
19+
const result = spawnSync('npm', ['view', spec, 'version'], { encoding: 'utf8' });
20+
if (result.status === 0) {
21+
throw new Error(`${spec} is already published`);
22+
}
23+
24+
const output = `${result.stdout ?? ''}\n${result.stderr ?? ''}`;
25+
if (!/E404|404 Not Found/i.test(output)) {
26+
process.stderr.write(output);
27+
throw new Error(`could not verify npm availability for ${spec}`);
28+
}
29+
}
30+
31+
console.log(`Validated ${manifest.name}@${version}`);

src/engine.ts

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1-
import { getNativeAddon, type NativeAddon, type NativeModel, type NativeContext, type NativeMtmdContext } from './native.js';
1+
import {
2+
getNativeAddon,
3+
type NativeAddon,
4+
type NativeContext,
5+
type NativeEmbeddingContext,
6+
type NativeModel,
7+
type NativeMtmdContext,
8+
} from './native.js';
29
import type {
310
ModelOptions,
411
ContextOptions,
@@ -832,10 +839,10 @@ function poolingTypeToNative(pooling: EmbeddingPoolingType): number {
832839
export class EmbeddingContext {
833840
private native: NativeAddon;
834841
private modelHandle: NativeModel;
835-
private handle: NativeContext | null;
842+
private handle: NativeEmbeddingContext | null;
836843
private dimensionValue: number;
837844

838-
constructor(native: NativeAddon, modelHandle: NativeModel, handle: NativeContext, dimension: number) {
845+
constructor(native: NativeAddon, modelHandle: NativeModel, handle: NativeEmbeddingContext, dimension: number) {
839846
this.native = native;
840847
this.modelHandle = modelHandle;
841848
this.handle = handle;
@@ -846,24 +853,26 @@ export class EmbeddingContext {
846853
return this.dimensionValue;
847854
}
848855

849-
private ensureHandle(): NativeContext {
856+
private ensureHandle(): NativeEmbeddingContext {
850857
if (!this.handle) throw new Error('EmbeddingContext has been disposed');
851858
return this.handle;
852859
}
853860

854861
embed(text: string): Float32Array {
862+
const handle = this.ensureHandle();
855863
const tokens = this.native.tokenize(this.modelHandle, text);
856-
return this.native.embed(this.ensureHandle(), this.modelHandle, tokens);
864+
return this.native.embed(handle, this.modelHandle, tokens);
857865
}
858866

859867
embedBatch(texts: string[]): Float32Array[] {
868+
const handle = this.ensureHandle();
860869
const tokenArrays = texts.map((text) => this.native.tokenize(this.modelHandle, text));
861-
return this.native.embedBatch(this.ensureHandle(), this.modelHandle, tokenArrays);
870+
return this.native.embedBatch(handle, this.modelHandle, tokenArrays);
862871
}
863872

864873
dispose(): void {
865874
if (this.handle) {
866-
this.native.freeContext(this.handle);
875+
this.native.freeEmbeddingContext(this.handle);
867876
this.handle = null;
868877
}
869878
}

src/native.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ type Brand<T, B> = T & { readonly [__brand]: B };
33

44
export type NativeModel = Brand<object, 'NativeModel'>;
55
export type NativeContext = Brand<object, 'NativeContext'>;
6+
export type NativeEmbeddingContext = Brand<object, 'NativeEmbeddingContext'>;
67
export type NativeMtmdContext = Brand<object, 'NativeMtmdContext'>;
78

89
export interface NativeAddon {
@@ -181,10 +182,11 @@ export interface NativeAddon {
181182
createEmbeddingContext(
182183
model: NativeModel,
183184
options?: { n_ctx?: number; n_batch?: number; n_threads?: number; pooling_type?: number },
184-
): NativeContext;
185+
): NativeEmbeddingContext;
185186

186-
embed(ctx: NativeContext, model: NativeModel, tokens: Int32Array): Float32Array;
187-
embedBatch(ctx: NativeContext, model: NativeModel, tokenArrays: Int32Array[]): Float32Array[];
187+
freeEmbeddingContext(ctx: NativeEmbeddingContext): void;
188+
embed(ctx: NativeEmbeddingContext, model: NativeModel, tokens: Int32Array): Float32Array;
189+
embedBatch(ctx: NativeEmbeddingContext, model: NativeModel, tokenArrays: Int32Array[]): Float32Array[];
188190

189191
quantize(
190192
inputPath: string,

tests/embedding-context.test.mjs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import assert from 'node:assert/strict';
2+
import test from 'node:test';
3+
4+
import { EmbeddingContext } from '../dist/index.js';
5+
6+
test('EmbeddingContext uses the embedding destructor exactly once', () => {
7+
const model = {};
8+
const handle = {};
9+
const calls = [];
10+
const native = {
11+
freeContext() {
12+
assert.fail('EmbeddingContext must not call freeContext');
13+
},
14+
freeEmbeddingContext(value) {
15+
calls.push(value);
16+
},
17+
};
18+
19+
const context = new EmbeddingContext(native, model, handle, 384);
20+
context.dispose();
21+
context.dispose();
22+
23+
assert.deepEqual(calls, [handle]);
24+
assert.throws(() => context.embed('disposed'), /EmbeddingContext has been disposed/);
25+
});

0 commit comments

Comments
 (0)