@gmickel/gno 0.6.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/llm/cache.ts CHANGED
@@ -5,18 +5,24 @@
5
5
  * @module src/llm/cache
6
6
  */
7
7
 
8
- import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
8
+ // node:crypto: createHash for safe lock filenames
9
+ import { createHash } from 'node:crypto';
10
+ import { mkdir, open, readFile, rename, rm, stat } from 'node:fs/promises';
9
11
  // node:path: join for path construction, isAbsolute for cross-platform path detection
10
12
  import { isAbsolute, join } from 'node:path';
11
13
  // node:url: fileURLToPath for proper file:// URL handling
12
14
  import { fileURLToPath } from 'node:url';
13
15
  import { getModelsCachePath } from '../app/constants';
14
16
  import {
17
+ autoDownloadDisabledError,
15
18
  downloadFailedError,
16
19
  invalidUriError,
20
+ lockFailedError,
17
21
  modelNotCachedError,
18
22
  modelNotFoundError,
19
23
  } from './errors';
24
+ import { getLockPath, getManifestLockPath, withLock } from './lockfile';
25
+ import type { DownloadPolicy } from './policy';
20
26
  import type {
21
27
  DownloadProgress,
22
28
  LlmResult,
@@ -306,6 +312,85 @@ export class ModelCache {
306
312
  }
307
313
  }
308
314
 
315
+ /**
316
+ * Ensure a model is available, downloading if necessary.
317
+ * Uses double-check locking pattern for concurrent safety.
318
+ *
319
+ * @param uri - Model URI (hf: or file:)
320
+ * @param type - Model type for manifest
321
+ * @param policy - Download policy (offline, allowDownload)
322
+ * @param onProgress - Optional progress callback
323
+ */
324
+ async ensureModel(
325
+ uri: string,
326
+ type: ModelType,
327
+ policy: DownloadPolicy,
328
+ onProgress?: ProgressCallback
329
+ ): Promise<LlmResult<string>> {
330
+ // Fast path: check if already cached
331
+ const cached = await this.getCachedPath(uri);
332
+ if (cached) {
333
+ return { ok: true, value: cached };
334
+ }
335
+
336
+ // Parse and validate URI
337
+ const parsed = parseModelUri(uri);
338
+ if (!parsed.ok) {
339
+ return { ok: false, error: invalidUriError(uri, parsed.error) };
340
+ }
341
+
342
+ // Local files: just verify existence (no download needed)
343
+ if (parsed.value.scheme === 'file') {
344
+ const exists = await this.fileExists(parsed.value.file);
345
+ if (!exists) {
346
+ return {
347
+ ok: false,
348
+ error: modelNotFoundError(
349
+ uri,
350
+ `File not found: ${parsed.value.file}`
351
+ ),
352
+ };
353
+ }
354
+ return { ok: true, value: parsed.value.file };
355
+ }
356
+
357
+ // HF models: check policy
358
+ if (policy.offline) {
359
+ return { ok: false, error: modelNotCachedError(uri, type) };
360
+ }
361
+
362
+ if (!policy.allowDownload) {
363
+ return { ok: false, error: autoDownloadDisabledError(uri) };
364
+ }
365
+
366
+ // Acquire lock for download (prevents concurrent downloads of same model)
367
+ // Use hash for lock filename to avoid collisions and path issues
368
+ await mkdir(this.dir, { recursive: true });
369
+ const lockName = createHash('sha256')
370
+ .update(uri)
371
+ .digest('hex')
372
+ .slice(0, 32);
373
+ const lockPath = getLockPath(join(this.dir, lockName));
374
+
375
+ const result = await withLock(lockPath, async () => {
376
+ // Double-check: another process may have downloaded while we waited
377
+ const cachedNow = await this.getCachedPath(uri);
378
+ if (cachedNow) {
379
+ return { ok: true as const, value: cachedNow };
380
+ }
381
+
382
+ // Download with progress
383
+ return this.download(uri, type, onProgress);
384
+ });
385
+
386
+ // withLock returns null if lock acquisition failed
387
+ if (result === null) {
388
+ return { ok: false, error: lockFailedError(uri) };
389
+ }
390
+
391
+ return result;
392
+ }
393
+
309
394
  /**
310
395
  * Check if a model is cached/available.
311
396
  * For file: URIs, checks if file exists on disk.
@@ -368,6 +453,7 @@ export class ModelCache {
368
453
  * If types provided, only clears models of those types.
369
454
  */
370
455
  async clear(types?: ModelType[]): Promise<void> {
456
+ // First, read manifest to get paths to delete (outside lock for IO)
371
457
  const manifest = await this.loadManifest();
372
458
 
373
459
  const toRemove = types
@@ -382,14 +468,14 @@ export class ModelCache {
382
468
  }
383
469
  }
384
470
 
385
- // Update manifest
386
- if (types) {
387
- manifest.models = manifest.models.filter((m) => !types.includes(m.type));
388
- } else {
389
- manifest.models = [];
390
- }
391
-
392
- await this.saveManifest(manifest);
471
+ // Update manifest under lock
472
+ await this.updateManifest((m) => {
473
+ if (types) {
474
+ m.models = m.models.filter((model) => !types.includes(model.type));
475
+ } else {
476
+ m.models = [];
477
+ }
478
+ });
393
479
  }
394
480
 
395
481
  // ───────────────────────────────────────────────────────────────────────────
@@ -410,58 +496,122 @@ export class ModelCache {
410
496
  return this.manifest;
411
497
  }
412
498
 
499
+ this.manifest = await this.readManifestFromDisk();
500
+ return this.manifest;
501
+ }
502
+
503
+ /**
504
+ * Read manifest from disk without cache (for use under lock).
505
+ */
506
+ private async readManifestFromDisk(): Promise<Manifest> {
413
507
  try {
414
508
  const content = await readFile(this.manifestPath, 'utf-8');
415
- this.manifest = JSON.parse(content) as Manifest;
416
- return this.manifest;
509
+ return JSON.parse(content) as Manifest;
417
510
  } catch {
418
511
  // No manifest or invalid - create empty
419
- this.manifest = { version: MANIFEST_VERSION, models: [] };
420
- return this.manifest;
512
+ return { version: MANIFEST_VERSION, models: [] };
421
513
  }
422
514
  }
423
515
 
424
- private async saveManifest(manifest: Manifest): Promise<void> {
516
+ /**
517
+ * Atomically update manifest under lock.
518
+ * Uses read-modify-write pattern with cross-process locking to prevent lost updates.
519
+ */
520
+ private async updateManifest(
521
+ mutator: (manifest: Manifest) => void
522
+ ): Promise<void> {
425
523
  await mkdir(this.dir, { recursive: true });
426
- await writeFile(this.manifestPath, JSON.stringify(manifest, null, 2));
427
- this.manifest = manifest;
524
+
525
+ const lockPath = getManifestLockPath(this.dir);
526
+ const result = await withLock(lockPath, async () => {
527
+ // Read current manifest from disk (not cache) under lock
528
+ const manifest = await this.readManifestFromDisk();
529
+
530
+ // Apply mutation
531
+ mutator(manifest);
532
+
533
+ // Write atomically
534
+ await this.writeManifestAtomically(manifest);
535
+
536
+ // Update cache
537
+ this.manifest = manifest;
538
+ return true;
539
+ });
540
+
541
+ if (result === null) {
542
+ throw new Error('Failed to acquire manifest lock');
543
+ }
544
+ }
545
+
546
+ /**
547
+ * Atomically write manifest with fsync for durability.
548
+ * Uses write-to-temp + fsync + rename pattern.
549
+ * Must be called under manifest lock.
550
+ */
551
+ private async writeManifestAtomically(manifest: Manifest): Promise<void> {
552
+ const tmpPath = `${this.manifestPath}.${process.pid}.tmp`;
553
+ const content = JSON.stringify(manifest, null, 2);
554
+
555
+ // Write to temp file with fsync
556
+ const fh = await open(tmpPath, 'w');
557
+ try {
558
+ await fh.writeFile(content);
559
+ await fh.sync();
560
+ } finally {
561
+ await fh.close();
562
+ }
563
+
564
+ // Atomic rename
565
+ await rename(tmpPath, this.manifestPath);
566
+
567
+ // Fsync parent directory for rename durability (best-effort, not supported on Windows)
568
+ if (process.platform !== 'win32') {
569
+ try {
570
+ const dirFh = await open(this.dir, 'r');
571
+ try {
572
+ await dirFh.sync();
573
+ } finally {
574
+ await dirFh.close();
575
+ }
576
+ } catch {
577
+ // Best-effort durability
578
+ }
579
+ }
428
580
  }
429
581
 
430
582
  private async addToManifest(
431
583
  uri: string,
432
584
  type: ModelType,
433
- path: string
585
+ modelPath: string
434
586
  ): Promise<void> {
435
- const manifest = await this.loadManifest();
436
-
437
- // Get file size and compute checksum
587
+ // Get file size outside lock (IO-bound, doesn't need protection)
438
588
  let size = 0;
439
589
  try {
440
- const stats = await stat(path);
590
+ const stats = await stat(modelPath);
441
591
  size = stats.size;
442
592
  } catch {
443
593
  // Ignore
444
594
  }
445
595
 
446
- // Remove existing entry if present
447
- manifest.models = manifest.models.filter((m) => m.uri !== uri);
448
-
449
- // Add new entry
450
- manifest.models.push({
451
- uri,
452
- type,
453
- path,
454
- size,
455
- checksum: '', // TODO: compute SHA-256 for large files
456
- cachedAt: new Date().toISOString(),
596
+ await this.updateManifest((manifest) => {
597
+ // Remove existing entry if present
598
+ manifest.models = manifest.models.filter((m) => m.uri !== uri);
599
+
600
+ // Add new entry
601
+ manifest.models.push({
602
+ uri,
603
+ type,
604
+ path: modelPath,
605
+ size,
606
+ checksum: '', // TODO: compute SHA-256 for large files
607
+ cachedAt: new Date().toISOString(),
608
+ });
457
609
  });
458
-
459
- await this.saveManifest(manifest);
460
610
  }
461
611
 
462
612
  private async removeFromManifest(uri: string): Promise<void> {
463
- const manifest = await this.loadManifest();
464
- manifest.models = manifest.models.filter((m) => m.uri !== uri);
465
- await this.saveManifest(manifest);
613
+ await this.updateManifest((manifest) => {
614
+ manifest.models = manifest.models.filter((m) => m.uri !== uri);
615
+ });
466
616
  }
467
617
  }
package/src/llm/errors.ts CHANGED
@@ -18,7 +18,9 @@ export type LlmErrorCode =
18
18
  | 'INFERENCE_FAILED'
19
19
  | 'TIMEOUT'
20
20
  | 'OUT_OF_MEMORY'
21
- | 'INVALID_URI';
21
+ | 'INVALID_URI'
22
+ | 'LOCK_FAILED'
23
+ | 'AUTO_DOWNLOAD_DISABLED';
22
24
 
23
25
  export interface LlmError {
24
26
  code: LlmErrorCode;
@@ -91,9 +93,12 @@ export function llmError(
91
93
  * Check if error is retryable.
92
94
  */
93
95
  export function isRetryable(code: LlmErrorCode): boolean {
94
- return ['MODEL_DOWNLOAD_FAILED', 'TIMEOUT', 'INFERENCE_FAILED'].includes(
95
- code
96
- );
96
+ return [
97
+ 'MODEL_DOWNLOAD_FAILED',
98
+ 'TIMEOUT',
99
+ 'INFERENCE_FAILED',
100
+ 'LOCK_FAILED',
101
+ ].includes(code);
97
102
  }
98
103
 
99
104
  // ─────────────────────────────────────────────────────────────────────────────
@@ -189,3 +194,21 @@ export function invalidUriError(uri: string, details: string): LlmError {
189
194
  retryable: false,
190
195
  });
191
196
  }
197
+
198
+ export function lockFailedError(uri: string): LlmError {
199
+ return llmError('LOCK_FAILED', {
200
+ message: `Failed to acquire lock for model download: ${uri}`,
201
+ modelUri: uri,
202
+ retryable: true,
203
+ suggestion: 'Another process may be downloading. Wait and retry.',
204
+ });
205
+ }
206
+
207
+ export function autoDownloadDisabledError(uri: string): LlmError {
208
+ return llmError('AUTO_DOWNLOAD_DISABLED', {
209
+ message: `Model not cached and auto-download disabled: ${uri}`,
210
+ modelUri: uri,
211
+ retryable: false,
212
+ suggestion: "Run 'gno models pull' to download models manually.",
213
+ });
214
+ }
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Cross-process lockfile for model cache operations.
3
+ * Uses O_EXCL create + stale lock recovery pattern.
4
+ *
5
+ * @module src/llm/lockfile
6
+ */
7
+
8
+ import { open, rename, rm, stat } from 'node:fs/promises';
9
+ // node:os: hostname and user for lock ownership
10
+ import { hostname, userInfo } from 'node:os';
11
+ // node:path: join for manifest lock path
12
+ import { join } from 'node:path';
13
+
14
+ // ─────────────────────────────────────────────────────────────────────────────
15
+ // Constants
16
+ // ─────────────────────────────────────────────────────────────────────────────
17
+
18
+ /** Default lock TTL in milliseconds (24 hours - long to avoid stealing during slow downloads) */
19
+ const DEFAULT_LOCK_TTL_MS = 24 * 60 * 60 * 1000;
20
+
21
+ /** Retry delay for lock acquisition (ms) */
22
+ const LOCK_RETRY_DELAY_MS = 500;
23
+
24
+ /** Max retries before giving up (~10 minutes for multi-GB downloads) */
25
+ const LOCK_MAX_RETRIES = 1200;
26
+
27
+ // ─────────────────────────────────────────────────────────────────────────────
28
+ // Types
29
+ // ─────────────────────────────────────────────────────────────────────────────
30
+
31
+ interface LockMeta {
32
+ pid: number;
33
+ hostname: string;
34
+ user: string;
35
+ createdAt: string;
36
+ }
37
+
38
+ export interface LockHandle {
39
+ /** Release the lock */
40
+ release: () => Promise<void>;
41
+ /** Path to lock file */
42
+ path: string;
43
+ }
44
+
45
+ export interface LockOptions {
46
+ /** Lock TTL in milliseconds (see DEFAULT_LOCK_TTL_MS) */
47
+ ttlMs?: number;
48
+ /** Max retries before giving up (see LOCK_MAX_RETRIES) */
49
+ maxRetries?: number;
50
+ /** Delay between retries in ms (see LOCK_RETRY_DELAY_MS) */
51
+ retryDelayMs?: number;
52
+ }
53
+
54
+ // ─────────────────────────────────────────────────────────────────────────────
55
+ // Helpers
56
+ // ─────────────────────────────────────────────────────────────────────────────
57
+
58
+ function getLockMeta(): LockMeta {
59
+ return {
60
+ pid: process.pid,
61
+ hostname: hostname(),
62
+ user: userInfo().username,
63
+ createdAt: new Date().toISOString(),
64
+ };
65
+ }
66
+
67
+ function sleep(ms: number): Promise<void> {
68
+ return new Promise((resolve) => setTimeout(resolve, ms));
69
+ }
70
+
71
+ /**
72
+ * Check if a lockfile is stale (older than TTL or owner process dead).
73
+ */
74
+ async function isLockStale(lockPath: string, ttlMs: number): Promise<boolean> {
75
+ try {
76
+ const stats = await stat(lockPath);
77
+ const age = Date.now() - stats.mtimeMs;
78
+
79
+ // Lock older than TTL is definitely stale
80
+ if (age > ttlMs) {
81
+ return true;
82
+ }
83
+
84
+ // TODO: Could also check if PID is alive on same hostname
85
+ // For now, just use TTL-based staleness
86
+ return false;
87
+ } catch {
88
+ // Lock doesn't exist or can't be read
89
+ return true;
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Create lock file exclusively (O_EXCL).
95
+ * Fails if file already exists.
96
+ */
97
+ async function createLockExclusive(
98
+ lockPath: string,
99
+ meta: LockMeta
100
+ ): Promise<void> {
101
+ const content = JSON.stringify(meta, null, 2);
102
+
103
+ // Create lock file with O_EXCL - fails if exists
104
+ const fh = await open(lockPath, 'wx');
105
+ try {
106
+ await fh.writeFile(content);
107
+ await fh.sync();
108
+ } finally {
109
+ await fh.close();
110
+ }
111
+ }
112
+
113
+ // ─────────────────────────────────────────────────────────────────────────────
114
+ // Main API
115
+ // ─────────────────────────────────────────────────────────────────────────────
116
+
117
+ /**
118
+ * Acquire a lock on a path.
119
+ * Returns a handle that must be released when done.
120
+ *
121
+ * @param lockPath - Path to the lock file (usually model path + '.lock')
122
+ * @param options - Lock options
123
+ * @returns Lock handle or null if acquisition failed
124
+ */
125
+ export async function acquireLock(
126
+ lockPath: string,
127
+ options?: LockOptions
128
+ ): Promise<LockHandle | null> {
129
+ const ttlMs = options?.ttlMs ?? DEFAULT_LOCK_TTL_MS;
130
+ const maxRetries = options?.maxRetries ?? LOCK_MAX_RETRIES;
131
+ const retryDelayMs = options?.retryDelayMs ?? LOCK_RETRY_DELAY_MS;
132
+
133
+ let retries = 0;
134
+
135
+ while (retries < maxRetries) {
136
+ try {
137
+ // Try to create lock file exclusively
138
+ const meta = getLockMeta();
139
+ await createLockExclusive(lockPath, meta);
140
+
141
+ // Success! Return handle
142
+ return {
143
+ path: lockPath,
144
+ release: async () => {
145
+ await rm(lockPath, { force: true }).catch(() => undefined);
146
+ },
147
+ };
148
+ } catch (e) {
149
+ // EEXIST means lock exists
150
+ if (e && typeof e === 'object' && 'code' in e && e.code === 'EEXIST') {
151
+ // Check if stale
152
+ const stale = await isLockStale(lockPath, ttlMs);
153
+
154
+ if (stale) {
155
+ // Atomic stale recovery: rename to .stale, then try again
156
+ const stalePath = `${lockPath}.stale.${process.pid}`;
157
+ try {
158
+ await rename(lockPath, stalePath);
159
+ // Clean up stale file (ignore errors)
160
+ await rm(stalePath, { force: true }).catch(() => undefined);
161
+ // Try again immediately
162
+ continue;
163
+ } catch {
164
+ // Someone else grabbed it - retry with backoff
165
+ }
166
+ }
167
+
168
+ // Lock is held - wait and retry
169
+ retries++;
170
+ await sleep(retryDelayMs);
171
+ continue;
172
+ }
173
+
174
+ // Other error (permissions, etc.)
175
+ throw e;
176
+ }
177
+ }
178
+
179
+ // Failed to acquire after max retries
180
+ return null;
181
+ }
182
+
183
+ /**
184
+ * Execute a function while holding a lock.
185
+ * Automatically releases lock when done.
186
+ */
187
+ export async function withLock<T>(
188
+ lockPath: string,
189
+ fn: () => Promise<T>,
190
+ options?: LockOptions
191
+ ): Promise<T | null> {
192
+ const lock = await acquireLock(lockPath, options);
193
+ if (!lock) {
194
+ return null;
195
+ }
196
+
197
+ try {
198
+ return await fn();
199
+ } finally {
200
+ await lock.release();
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Get the lock path for a model file path.
206
+ */
207
+ export function getLockPath(modelPath: string): string {
208
+ return `${modelPath}.lock`;
209
+ }
210
+
211
+ /**
212
+ * Get the manifest lock path for a cache directory.
213
+ */
214
+ export function getManifestLockPath(cacheDir: string): string {
215
+ return join(cacheDir, 'manifest.lock');
216
+ }
@@ -7,11 +7,13 @@
7
7
 
8
8
  import type { Config } from '../../config/types';
9
9
  import { ModelCache } from '../cache';
10
+ import type { DownloadPolicy } from '../policy';
10
11
  import { getActivePreset, getModelConfig } from '../registry';
11
12
  import type {
12
13
  EmbeddingPort,
13
14
  GenerationPort,
14
15
  LlmResult,
16
+ ProgressCallback,
15
17
  RerankPort,
16
18
  } from '../types';
17
19
  import { NodeLlamaCppEmbedding } from './embedding';
@@ -19,6 +21,20 @@ import { NodeLlamaCppGeneration } from './generation';
19
21
  import { getModelManager, type ModelManager } from './lifecycle';
20
22
  import { NodeLlamaCppRerank } from './rerank';
21
23
 
24
+ // ─────────────────────────────────────────────────────────────────────────────
25
+ // Types
26
+ // ─────────────────────────────────────────────────────────────────────────────
27
+
28
+ export interface CreatePortOptions {
29
+ /** Download policy (offline, allowDownload) */
30
+ policy?: DownloadPolicy;
31
+ /** Progress callback for downloads */
32
+ onProgress?: ProgressCallback;
33
+ }
34
+
35
+ /** Default policy: no auto-download (backwards compatible) */
36
+ const DEFAULT_POLICY: DownloadPolicy = { offline: false, allowDownload: false };
37
+
22
38
  // ─────────────────────────────────────────────────────────────────────────────
23
39
  // Adapter
24
40
  // ─────────────────────────────────────────────────────────────────────────────
@@ -37,15 +53,23 @@ export class LlmAdapter {
37
53
 
38
54
  /**
39
55
  * Create an embedding port.
56
+ * With options.policy.allowDownload=true, auto-downloads if not cached.
40
57
  */
41
58
  async createEmbeddingPort(
42
- modelUri?: string
59
+ modelUri?: string,
60
+ options?: CreatePortOptions
43
61
  ): Promise<LlmResult<EmbeddingPort>> {
44
62
  const preset = getActivePreset(this.config);
45
63
  const uri = modelUri ?? preset.embed;
46
-
47
- // Resolve model path from cache
48
- const resolved = await this.cache.resolve(uri, 'embed');
64
+ const policy = options?.policy ?? DEFAULT_POLICY;
65
+
66
+ // Ensure model is available (downloads if policy allows)
67
+ const resolved = await this.cache.ensureModel(
68
+ uri,
69
+ 'embed',
70
+ policy,
71
+ options?.onProgress
72
+ );
49
73
  if (!resolved.ok) {
50
74
  return resolved;
51
75
  }
@@ -58,15 +82,23 @@ export class LlmAdapter {
58
82
 
59
83
  /**
60
84
  * Create a generation port.
85
+ * With options.policy.allowDownload=true, auto-downloads if not cached.
61
86
  */
62
87
  async createGenerationPort(
63
- modelUri?: string
88
+ modelUri?: string,
89
+ options?: CreatePortOptions
64
90
  ): Promise<LlmResult<GenerationPort>> {
65
91
  const preset = getActivePreset(this.config);
66
92
  const uri = modelUri ?? preset.gen;
67
-
68
- // Resolve model path from cache
69
- const resolved = await this.cache.resolve(uri, 'gen');
93
+ const policy = options?.policy ?? DEFAULT_POLICY;
94
+
95
+ // Ensure model is available (downloads if policy allows)
96
+ const resolved = await this.cache.ensureModel(
97
+ uri,
98
+ 'gen',
99
+ policy,
100
+ options?.onProgress
101
+ );
70
102
  if (!resolved.ok) {
71
103
  return resolved;
72
104
  }
@@ -79,13 +111,23 @@ export class LlmAdapter {
79
111
 
80
112
  /**
81
113
  * Create a rerank port.
114
+ * With options.policy.allowDownload=true, auto-downloads if not cached.
82
115
  */
83
- async createRerankPort(modelUri?: string): Promise<LlmResult<RerankPort>> {
116
+ async createRerankPort(
117
+ modelUri?: string,
118
+ options?: CreatePortOptions
119
+ ): Promise<LlmResult<RerankPort>> {
84
120
  const preset = getActivePreset(this.config);
85
121
  const uri = modelUri ?? preset.rerank;
86
-
87
- // Resolve model path from cache
88
- const resolved = await this.cache.resolve(uri, 'rerank');
122
+ const policy = options?.policy ?? DEFAULT_POLICY;
123
+
124
+ // Ensure model is available (downloads if policy allows)
125
+ const resolved = await this.cache.ensureModel(
126
+ uri,
127
+ 'rerank',
128
+ policy,
129
+ options?.onProgress
130
+ );
89
131
  if (!resolved.ok) {
90
132
  return resolved;
91
133
  }