arcane-os 0.96.0 → 0.97.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.
@@ -1,4 +1,6 @@
1
- import {mkdir, mkdtemp, open, readFile, readdir, realpath, rename, rm, stat, writeFile} from 'node:fs/promises';
1
+ import {mkdir, mkdtemp, open, readFile, readdir, realpath, rename, rm, rmdir, stat, unlink, writeFile} from 'node:fs/promises';
2
+ import {watch} from 'node:fs';
3
+ import {randomUUID} from 'node:crypto';
2
4
  import {Buffer} from 'node:buffer';
3
5
  import path from 'node:path';
4
6
  import Is from 'strong-type';
@@ -146,11 +148,16 @@ async function discoverDirectories(appRoot, {directories, catalog = [], recursiv
146
148
  catch (error) { if (error.code !== 'ENOENT') throw error; linked.set(entry, null); }
147
149
  }
148
150
  let acquisition;
151
+ let acquisitionComplete = false;
149
152
  if (path.basename(directory).startsWith('acquisition-')) {
150
- try { acquisition = JSON.parse(await readFile(path.join(directory, 'acquisition.json'), {encoding: 'utf8', signal})); }
151
- catch (error) {
152
- if (error.code !== 'ENOENT') throw error;
153
- if (path.basename(path.dirname(directory)) === 'model-assets') return;
153
+ try { acquisition = JSON.parse(await readFile(path.join(directory, 'acquisition.json'), {encoding: 'utf8', signal})); acquisitionComplete = true; }
154
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
155
+ if (!acquisition) {
156
+ try { acquisition = JSON.parse(await readFile(path.join(directory, 'transfer.json'), {encoding: 'utf8', signal})); }
157
+ catch (error) {
158
+ if (error.code !== 'ENOENT') throw error;
159
+ if (path.basename(path.dirname(directory)) === 'model-assets') return;
160
+ }
154
161
  }
155
162
  }
156
163
  if (acquisition) {
@@ -164,7 +171,9 @@ async function discoverDirectories(appRoot, {directories, catalog = [], recursiv
164
171
  && (member.archiveMember === undefined || member.archiveMember === original.archiveMember);
165
172
  });
166
173
  });
167
- add(discoveryRecord(files, members, 'acquisition', selection, discoveryEntry(groups, selection?.entryPath)));
174
+ const record = discoveryRecord(files, members, 'acquisition', selection, discoveryEntry(groups, selection?.entryPath));
175
+ if (!acquisitionComplete) record.complete = false;
176
+ add(record);
168
177
  return;
169
178
  }
170
179
  for (const selection of selections) {
@@ -193,31 +202,198 @@ async function discoverDirectories(appRoot, {directories, catalog = [], recursiv
193
202
  return {directories: roots, models};
194
203
  }
195
204
 
196
- async function storedAcquisition(directory, members, signal) {
197
- let latest;
205
+ async function storedAcquisitions(directory, members, signal) {
206
+ const acquisitions = [];
198
207
  for (const entry of await readdir(directory, {withFileTypes: true})) {
199
208
  signal.throwIfAborted();
200
209
  if (!entry.isDirectory() || !entry.name.startsWith('acquisition-')) continue;
201
210
  const root = path.join(directory, entry.name);
202
211
  let selected;
203
- try { selected = JSON.parse(await readFile(path.join(root, 'acquisition.json'), {encoding: 'utf8', signal})); }
204
- catch (error) { if (error.code === 'ENOENT') continue; throw error; }
212
+ let committed = false;
213
+ try { selected = JSON.parse(await readFile(path.join(root, 'acquisition.json'), {encoding: 'utf8', signal})); committed = true; }
214
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
215
+ if (!selected) {
216
+ try { selected = JSON.parse(await readFile(path.join(root, 'transfer.json'), {encoding: 'utf8', signal})); }
217
+ catch (error) { if (error.code === 'ENOENT') continue; throw error; }
218
+ }
205
219
  if (!sameMembers(selected.members, members)) continue;
206
- nativeAcquisitionOrder = Math.max(nativeAcquisitionOrder, selected.order);
220
+ nativeAcquisitionOrder = Math.max(nativeAcquisitionOrder, selected.order ?? 0);
207
221
  const files = path.join(root, 'files');
208
222
  const locations = members.map(function originalLocation(member) { return memberLocation(files, member); });
209
- let available = true;
210
- for (const member of locations) {
223
+ const completedMembers = [];
224
+ for (const [index, member] of locations.entries()) {
225
+ signal.throwIfAborted();
226
+ // Only an atomically promoted final filename is a completed member
227
+ // in the resumable layout. Older completion records keep their meaning.
228
+ try { if ((await stat(member.nativePath)).isFile()) completedMembers.push(index); }
229
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
230
+ }
231
+ acquisitions.push({root, name: entry.name, order: selected.order ?? 0, refresh: selected.refresh === true,
232
+ complete: committed && completedMembers.length === members.length, completedMembers, directory: files, members: locations});
233
+ }
234
+ acquisitions.sort(function newestAcquisition(left, right) { return right.order - left.order || right.name.localeCompare(left.name); });
235
+ return acquisitions;
236
+ }
237
+
238
+ async function saveAcquisitionState(root, name, record) {
239
+ const pending = path.join(root, `${name}.${randomUUID()}.pending`);
240
+ await writeFile(pending, JSON.stringify(record), {encoding: 'utf8', flag: 'wx'});
241
+ await rename(pending, path.join(root, name));
242
+ }
243
+
244
+ function processExists(pid) {
245
+ try { process.kill(pid, 0); return true; }
246
+ catch (error) { if (error.code === 'ESRCH') return false; throw error; }
247
+ }
248
+
249
+ /** Atomic directory publication keeps an owner record visible with its lock. */
250
+ async function acquireDirectoryOwner(directory, name, signal, waiting) {
251
+ const lock = path.join(directory, name);
252
+ const candidate = await mkdtemp(path.join(directory, `${name}-candidate-`));
253
+ const owner = {pid: process.pid, token: randomUUID(), acquiredAt: new Date().toISOString(),
254
+ scope: lock, expires: 'When the recorded process exits; live ownership has no elapsed-time expiry.',
255
+ release: 'Retire the owned directory atomically after owned I/O settles, then remove its own metadata.',
256
+ recovery: 'A contender may recover this directory only after its recorded process has exited.'};
257
+ let acquired = false;
258
+ async function retireOwner(selectedLock) {
259
+ const retired = path.join(directory, `${name}-released-${randomUUID()}`);
260
+ await rename(selectedLock, retired);
261
+ await unlink(path.join(retired, 'owner.json'));
262
+ await rmdir(retired);
263
+ }
264
+ let acquisitionError;
265
+ try {
266
+ await writeFile(path.join(candidate, 'owner.json'), JSON.stringify(owner), {encoding: 'utf8', flag: 'wx'});
267
+ while (!acquired) {
211
268
  signal.throwIfAborted();
212
- try { if (!(await stat(member.nativePath)).isFile()) available = false; }
213
- catch (error) { if (error.code !== 'ENOENT') throw error; available = false; }
269
+ let notify;
270
+ let rejectWait;
271
+ const changed = new Promise(function observeOwnerChange(resolve, reject) { notify = resolve; rejectWait = reject; });
272
+ changed.catch(function observeWaitRejection() {});
273
+ const watcher = watch(directory, function ownerChanged() { notify(); });
274
+ watcher.on('error', rejectWait);
275
+ // A crashed process emits no filesystem event. This heartbeat checks
276
+ // that exact owner, and also keeps a live wait observable to callers.
277
+ const heartbeat = setTimeout(notify, 5000);
278
+ heartbeat.unref?.();
279
+ function aborted() { rejectWait(signal.reason); }
280
+ signal.addEventListener('abort', aborted, {once: true});
281
+ try {
282
+ try { await rename(candidate, lock); acquired = true; }
283
+ catch (error) {
284
+ if (!['EEXIST', 'ENOTEMPTY', 'EPERM', 'EACCES'].includes(error.code)) throw error;
285
+ let active;
286
+ try { active = JSON.parse(await readFile(path.join(lock, 'owner.json'), 'utf8')); }
287
+ catch (readError) {
288
+ if (readError.code !== 'ENOENT') throw readError;
289
+ try {
290
+ // Empty ownerless directories can remain from an
291
+ // older interrupted release. rmdir cannot remove a
292
+ // newly published nonempty owner directory.
293
+ await rmdir(lock);
294
+ continue;
295
+ } catch (emptyError) {
296
+ if (emptyError.code === 'ENOENT') {
297
+ if (error.code === 'EPERM' || error.code === 'EACCES') throw error;
298
+ continue;
299
+ }
300
+ if (!['ENOTEMPTY', 'EEXIST'].includes(emptyError.code)) throw emptyError;
301
+ }
302
+ }
303
+ if (active && !processExists(active.pid)) {
304
+ let current;
305
+ try { current = JSON.parse(await readFile(path.join(lock, 'owner.json'), 'utf8')); }
306
+ catch (readError) { if (readError.code === 'ENOENT') continue; throw readError; }
307
+ // The old process may have released normally and then
308
+ // exited after our first read. Do not retire its successor.
309
+ if (current.token !== active.token) continue;
310
+ // Keep this nonempty operation record after recovery.
311
+ // A delayed contender for the same dead owner cannot
312
+ // replace it with a newly published live owner's lock.
313
+ const retired = path.join(directory, `${name}-exited-${active.token}`);
314
+ try { await rename(lock, retired); }
315
+ catch (recoveryError) {
316
+ if (recoveryError.code === 'ENOENT') continue;
317
+ if (!['EEXIST', 'ENOTEMPTY', 'EPERM', 'EACCES'].includes(recoveryError.code)) throw recoveryError;
318
+ const recovered = JSON.parse(await readFile(path.join(retired, 'owner.json'), 'utf8'));
319
+ if (recovered.token !== active.token) throw recoveryError;
320
+ }
321
+ continue;
322
+ }
323
+ await waiting?.(active);
324
+ signal.throwIfAborted();
325
+ await changed;
326
+ }
327
+ } finally {
328
+ clearTimeout(heartbeat);
329
+ watcher.close();
330
+ signal.removeEventListener('abort', aborted);
331
+ }
332
+ }
333
+ } catch (error) { acquisitionError = error; throw error; }
334
+ finally {
335
+ if (!acquired) {
336
+ try {
337
+ try { await unlink(path.join(candidate, 'owner.json')); }
338
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
339
+ await rmdir(candidate);
340
+ } catch (error) {
341
+ if (acquisitionError) throw new AggregateError([acquisitionError, error], 'Acquiring model ownership and releasing its candidate failed.');
342
+ throw error;
343
+ }
344
+ }
345
+ }
346
+ return async function releaseDirectoryOwner() {
347
+ const current = JSON.parse(await readFile(path.join(lock, 'owner.json'), 'utf8'));
348
+ if (current.token !== owner.token) throw failure('MODEL_ASSET_OWNER_CHANGED', 'Model acquisition ownership changed before release.');
349
+ await retireOwner(lock);
350
+ };
351
+ }
352
+
353
+ async function retainRestartedPart(attempt, memberIndex) {
354
+ const partial = path.join(attempt, 'parts', `${memberIndex}.part`);
355
+ const previous = path.join(attempt, 'parts', `${memberIndex}.previous.part`);
356
+ try { await stat(previous); }
357
+ catch (error) {
358
+ if (error.code !== 'ENOENT') throw error;
359
+ try { await rename(partial, previous); return; }
360
+ catch (renameError) { if (renameError.code !== 'ENOENT') throw renameError; return; }
361
+ }
362
+ // Retain the earlier resumable original while replacing only this member's
363
+ // failed replacement. Repeated retries do not accumulate duplicate parts.
364
+ try { await unlink(partial); }
365
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
366
+ }
367
+
368
+ async function selectNativeAcquisition(entry, progress) {
369
+ const {directory, members, controller, refresh} = entry;
370
+ const {signal} = controller;
371
+ const release = await acquireDirectoryOwner(directory, '.selection-owner', signal, function waitingForSelection() { progress('waiting'); });
372
+ let selectionError;
373
+ try {
374
+ const stored = await storedAcquisitions(directory, members, signal);
375
+ if (!refresh) {
376
+ const complete = stored.find(function completeAcquisition(candidate) { return candidate.complete; });
377
+ if (complete) return complete;
214
378
  }
215
- if (available && (!latest || selected.order > latest.order
216
- || (selected.order === latest.order && entry.name > latest.name))) {
217
- latest = {name: entry.name, order: selected.order, directory: files, members: locations};
379
+ const resumable = stored.find(function incompleteAcquisition(candidate) { return !candidate.complete && (!refresh || candidate.refresh); });
380
+ if (resumable) return resumable;
381
+ const root = await mkdtemp(path.join(directory, 'acquisition-'));
382
+ const files = path.join(root, 'files');
383
+ await mkdir(files);
384
+ await mkdir(path.join(root, 'parts'));
385
+ const order = ++nativeAcquisitionOrder;
386
+ await saveAcquisitionState(root, 'transfer.json', {members, order, refresh, completedMembers: [], transports: {}});
387
+ return {root, order, refresh, complete: false, completedMembers: [], directory: files,
388
+ members: members.map(function selectedMember(member) { return memberLocation(files, member); })};
389
+ } catch (error) { selectionError = error; throw error; }
390
+ finally {
391
+ try { await release(); }
392
+ catch (error) {
393
+ if (selectionError) throw new AggregateError([selectionError, error], 'Selecting model assets and releasing selection ownership failed.');
394
+ throw error;
218
395
  }
219
396
  }
220
- return latest ? {directory: latest.directory, members: latest.members} : null;
221
397
  }
222
398
 
223
399
  async function fetchModelResponse(url, signal, range) {
@@ -287,7 +463,7 @@ async function extractTarRange(response, range, selected, found, entry, progress
287
463
  if (!pending.length && !ended) {
288
464
  const chunk = await reader.read();
289
465
  ended = chunk.done;
290
- if (!ended) pending = Buffer.from(chunk.value);
466
+ if (!ended) { pending = Buffer.from(chunk.value); entry.activity(); }
291
467
  }
292
468
  if (!pending.length && ended) {
293
469
  if (allowEnd && remaining === length) return false;
@@ -327,16 +503,16 @@ async function extractTarRange(response, range, selected, found, entry, progress
327
503
  if (range?.stopBefore && (memberName === range.stopBefore || path.posix.basename(memberName) === range.stopBefore)) break;
328
504
  const memberIndexes = selected.get(memberName);
329
505
  if (memberIndexes && (type === '0' || type === '\0')) {
330
- if (memberIndexes.some(function alreadyRead(index) { return found.has(index); })) {
331
- throw failure('MODEL_ASSET_ARCHIVE_FORMAT', `The selected TAR member occurs more than once: ${memberName}`);
332
- }
506
+ // TAR extraction is ordered: a later complete occurrence replaces
507
+ // the earlier file only after its own stream and file close.
333
508
  const handles = [];
334
509
  const errors = [];
335
510
  try {
336
511
  for (const memberIndex of memberIndexes) {
337
512
  const location = entry.location.members[memberIndex];
338
513
  await mkdir(path.dirname(location.nativePath), {recursive: true});
339
- handles.push(await open(location.nativePath, 'wx'));
514
+ const partial = await entry.archivePart(memberIndex);
515
+ handles.push(await open(partial, 'wx'));
340
516
  progress('download', memberIndex);
341
517
  }
342
518
  await consume(length, async function originalMemberContent(content) {
@@ -352,7 +528,7 @@ async function extractTarRange(response, range, selected, found, entry, progress
352
528
  if (errors.length) throw new AggregateError(errors, 'Extracting a model member and closing its files failed.');
353
529
  for (const memberIndex of memberIndexes) {
354
530
  found.add(memberIndex);
355
- completeMember(memberIndex);
531
+ await completeMember(memberIndex);
356
532
  }
357
533
  } else await consume(length);
358
534
  await consume((512 - length % 512) % 512);
@@ -365,98 +541,267 @@ async function extractTarRange(response, range, selected, found, entry, progress
365
541
  if (operationError) throw operationError;
366
542
  }
367
543
 
544
+ async function downloadNativeMember(entry, memberIndex, state, save, progress, completeMember, noteFailure) {
545
+ const member = entry.members[memberIndex];
546
+ const {signal} = entry.controller;
547
+ const partial = path.join(entry.attempt, 'parts', `${memberIndex}.part`);
548
+ let offset = 0;
549
+ try { offset = (await stat(partial)).size; }
550
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
551
+ const previous = state.transports[memberIndex];
552
+ let response;
553
+ let reader;
554
+ let handle;
555
+ let cancellation;
556
+ let ended = false;
557
+ let operationError;
558
+ let remaining;
559
+
560
+ async function restart(reason) {
561
+ progress('restart', memberIndex, false, {reason});
562
+ await retainRestartedPart(entry.attempt, memberIndex);
563
+ offset = 0;
564
+ }
565
+ async function request(headers) {
566
+ try { return await fetch(member.url, {signal, headers}); }
567
+ catch (error) { noteFailure(error, 'interrupted'); throw error; }
568
+ }
569
+ function cancelReader() {
570
+ if (!reader || ended) return;
571
+ cancellation ??= reader.cancel(signal.reason);
572
+ cancellation.catch(function observeReaderCancellation() {});
573
+ }
574
+ try {
575
+ signal.throwIfAborted();
576
+ if (offset && previous?.encoding && previous.encoding !== 'identity') await restart('The previous response used encoded HTTP content and cannot be appended by Range.');
577
+ const validator = previous?.etag && !previous.etag.startsWith('W/') ? previous.etag : previous?.lastModified;
578
+ if (offset && !validator) await restart('The source supplied no HTTP validator for continuing its earlier response.');
579
+ const headers = {'Accept-Encoding': 'identity'};
580
+ if (offset) {
581
+ headers.Range = `bytes=${offset}-`;
582
+ headers['If-Range'] = validator;
583
+ progress('resume', memberIndex);
584
+ } else progress('download', memberIndex);
585
+ response = await request(headers);
586
+ signal.throwIfAborted();
587
+ if (offset && response.status === 416) {
588
+ await response.body?.cancel();
589
+ response = undefined;
590
+ await restart('The server no longer accepts this saved HTTP range.');
591
+ response = await request({'Accept-Encoding': 'identity'});
592
+ }
593
+ if (!response.ok) {
594
+ const error = failure('MODEL_ASSET_DOWNLOAD_FAILED', `Model asset request failed: ${response.status} ${response.statusText}`);
595
+ error.url = member.url;
596
+ error.status = response.status;
597
+ error.response = await response.text();
598
+ noteFailure(error, response.status >= 500 || response.status === 408 || response.status === 429 ? 'interrupted' : 'failed');
599
+ throw error;
600
+ }
601
+ if (!response.body) throw failure('MODEL_ASSET_DOWNLOAD_FAILED', `Model asset response has no content stream: ${member.url}`);
602
+ if (offset && response.status === 200) await restart('The server returned the complete file instead of the saved HTTP range.');
603
+ if (response.status === 206) {
604
+ const range = /^bytes (\d+)-(\d+)\/(\d+)$/.exec(response.headers.get('content-range') ?? '');
605
+ const encoding = response.headers.get('content-encoding');
606
+ const validatorHeader = previous?.etag && !previous.etag.startsWith('W/') ? 'etag' : 'last-modified';
607
+ const currentValidator = response.headers.get(validatorHeader);
608
+ const changed = currentValidator && currentValidator !== validator;
609
+ if (!offset || !range || Number(range[1]) !== offset || Number(range[2]) < offset
610
+ || Number(range[2]) + 1 !== Number(range[3]) || (encoding && encoding !== 'identity') || changed) {
611
+ const error = failure('MODEL_ASSET_DOWNLOAD_RANGE_FAILED', 'The server did not return the requested continuation of this model file.');
612
+ error.url = member.url;
613
+ error.status = response.status;
614
+ throw error;
615
+ }
616
+ // Content-Range framing is private to this HTTP transfer. It never
617
+ // participates in model identity, admission or user-facing progress.
618
+ remaining = Number(range[2]) - Number(range[1]) + 1;
619
+ }
620
+ state.transports[memberIndex] = {
621
+ etag: response.headers.get('etag'), lastModified: response.headers.get('last-modified'),
622
+ encoding: response.headers.get('content-encoding')
623
+ };
624
+ await save();
625
+ handle = await open(partial, 'a');
626
+ reader = response.body.getReader();
627
+ signal.addEventListener('abort', cancelReader, {once: true});
628
+ if (signal.aborted) cancelReader();
629
+ while (true) {
630
+ signal.throwIfAborted();
631
+ let chunk;
632
+ try { chunk = await reader.read(); }
633
+ catch (error) { noteFailure(error, 'interrupted'); throw error; }
634
+ signal.throwIfAborted();
635
+ if (chunk.done) { ended = true; break; }
636
+ if (remaining !== undefined) {
637
+ remaining -= chunk.value.byteLength;
638
+ if (remaining < 0) throw failure('MODEL_ASSET_DOWNLOAD_RANGE_FAILED', 'The server exceeded its HTTP Content-Range.');
639
+ }
640
+ await handle.appendFile(chunk.value);
641
+ progress('download', memberIndex, true);
642
+ }
643
+ if (remaining !== undefined && remaining !== 0) throw failure('MODEL_ASSET_DOWNLOAD_RANGE_FAILED', 'The server ended before its HTTP Content-Range.');
644
+ signal.throwIfAborted();
645
+ } catch (error) { operationError = error; noteFailure(error, 'failed'); }
646
+ signal.removeEventListener('abort', cancelReader);
647
+ try {
648
+ if (reader && !ended) { cancellation ??= reader.cancel(operationError); await cancellation; }
649
+ else if (!reader && response?.body && !response.bodyUsed && !response.body.locked) await response.body.cancel(operationError);
650
+ } catch (error) {
651
+ if (error !== operationError) operationError = operationError ? new AggregateError([operationError, error], 'Downloading a model member and closing its response failed.') : error;
652
+ }
653
+ try { reader?.releaseLock(); }
654
+ catch (error) { operationError = operationError ? new AggregateError([operationError, error], 'Downloading a model member and releasing its reader failed.') : error; }
655
+ try { await handle?.close(); }
656
+ catch (error) { operationError = operationError ? new AggregateError([operationError, error], 'Downloading a model member and closing its file failed.') : error; }
657
+ if (operationError) { noteFailure(operationError, 'failed'); throw operationError; }
658
+ await completeMember(memberIndex);
659
+ }
660
+
368
661
  async function acquireNativeMembers(entry) {
369
- const {directory, members, controller} = entry;
662
+ const {members, controller} = entry;
370
663
  const {signal} = controller;
371
- let attempt;
372
664
  let completed = 0;
373
- let committed = false;
374
665
  let firstFailure;
666
+ let failureState;
667
+ let releaseOwner;
668
+ let state;
669
+ let stateTail = Promise.resolve();
670
+ let heartbeat;
671
+ let acquisitionError;
672
+ let lastActivity = Date.now();
673
+ entry.activity = function receivedTransportContent() { lastActivity = Date.now(); };
375
674
  const activity = new Map();
376
- function progress(phase, memberIndex, repeatedActivity = false) {
675
+ function noteFailure(error, selectedState) {
676
+ if (firstFailure || signal.aborted) return;
677
+ firstFailure = error;
678
+ failureState = selectedState;
679
+ controller.abort(error);
680
+ }
681
+ function save() {
682
+ const next = stateTail.then(function saveTransferState() { return saveAcquisitionState(entry.attempt, 'transfer.json', state); });
683
+ stateTail = next.catch(function observeStateFailure(error) { noteFailure(error, 'failed'); });
684
+ return next;
685
+ }
686
+ function progress(phase, memberIndex, repeatedActivity = false, details = {}) {
687
+ if (phase === 'download') lastActivity = Date.now();
377
688
  if (repeatedActivity) {
378
689
  const now = Date.now();
379
690
  if (now - (activity.get(memberIndex) ?? 0) < 1000) return;
380
691
  activity.set(memberIndex, now);
381
692
  }
382
- entry.publish({phase, completed, total: members.length, unit: 'files',
383
- ...(memberIndex === undefined ? {} : {memberIndex, path: members[memberIndex].path})});
693
+ const selectedState = details.state ?? ({waiting: 'waiting', stalled: 'stalled', interrupted: 'interrupted',
694
+ cancelled: 'cancelled', failed: 'failed', ready: 'ready'})[phase] ?? 'downloading';
695
+ const messages = {
696
+ waiting: 'Another application is preparing these model files. Waiting for its acquisition.',
697
+ downloading: 'Downloading model files.', stalled: 'The model download is still open; no recent data has arrived.',
698
+ interrupted: 'The model download was interrupted. Completed files and resumable data were kept.',
699
+ cancelled: 'The model download was cancelled. Completed files and resumable data were kept.',
700
+ failed: 'The model download failed. Existing files and resumable data were kept.', ready: 'Model files are ready.'
701
+ };
702
+ const notification = {phase, state: selectedState, message: phase === 'resume' ? 'Resuming the saved model download.'
703
+ : phase === 'restart' ? 'Restarting this model file because its saved HTTP continuation is unavailable. Earlier partial data was retained.'
704
+ : phase === 'reuse' ? 'Reusing downloaded model files.' : messages[selectedState],
705
+ completed, total: members.length, unit: 'files',
706
+ ...(memberIndex === undefined ? {} : {memberIndex, path: members[memberIndex].path}), ...details};
707
+ if (['interrupted', 'cancelled', 'failed'].includes(selectedState)) notification.recovery = 'Retry to reuse completed files and continue saved downloads.';
708
+ if (selectedState === 'stalled') notification.recovery = 'Wait for the source to respond, or cancel and retry later.';
709
+ if (state) state.progress = notification;
710
+ entry.publish(notification);
384
711
  }
385
712
  try {
386
713
  signal.throwIfAborted();
387
- const stored = await storedAcquisition(directory, members, signal);
388
- if (stored && !entry.refresh) {
389
- entry.location = stored;
714
+ const selected = await selectNativeAcquisition(entry, progress);
715
+ entry.attempt = selected.root;
716
+ entry.location = {directory: selected.directory, members: selected.members};
717
+ if (selected.complete) {
390
718
  completed = members.length;
391
719
  progress('reuse');
392
720
  progress('ready');
393
- return stored;
721
+ return entry.location;
394
722
  }
395
723
  signal.throwIfAborted();
396
- attempt = await mkdtemp(path.join(directory, 'acquisition-'));
397
- const files = path.join(attempt, 'files');
398
- await mkdir(files);
399
- entry.location = {directory: files, members: members.map(function selectedMember(member) { return memberLocation(files, member); })};
724
+ releaseOwner = await acquireDirectoryOwner(selected.root, '.transfer-owner', signal, async function waitingForTransfer() {
725
+ try {
726
+ const observed = JSON.parse(await readFile(path.join(selected.root, 'transfer.json'), 'utf8'));
727
+ completed = observed.completedMembers?.length ?? 0;
728
+ progress('waiting', undefined, false, {activity: observed.progress});
729
+ } catch (error) { if (error.code !== 'ENOENT') throw error; progress('waiting'); }
730
+ });
731
+ const current = (await storedAcquisitions(entry.directory, members, signal)).find(function ownedAttempt(candidate) { return candidate.root === selected.root; });
732
+ if (current?.complete) {
733
+ completed = members.length;
734
+ progress('reuse');
735
+ progress('ready');
736
+ return entry.location;
737
+ }
738
+ try { state = JSON.parse(await readFile(path.join(selected.root, 'transfer.json'), 'utf8')); }
739
+ catch (error) {
740
+ if (error.code !== 'ENOENT') throw error;
741
+ state = {members, order: selected.order, refresh: selected.refresh, transports: {}};
742
+ }
743
+ state.transports ??= {};
744
+ state.completedMembers = current?.completedMembers ?? [];
745
+ const finished = new Set(state.completedMembers);
746
+ const completedArchiveMembers = new Set(state.completedArchiveMembers ?? []);
747
+ completed = finished.size;
748
+ await mkdir(path.join(selected.root, 'parts'), {recursive: true});
400
749
  progress('open');
401
- function completeMember(memberIndex) {
402
- completed += 1;
750
+ await save();
751
+ heartbeat = setInterval(function reportTransferHeartbeat() {
752
+ const stalled = Date.now() - lastActivity >= 15000;
753
+ progress(stalled ? 'stalled' : 'heartbeat');
754
+ save().catch(function observeHeartbeatFailure(error) { noteFailure(error, 'failed'); });
755
+ }, 5000);
756
+ heartbeat.unref?.();
757
+ async function completeMember(memberIndex) {
758
+ const location = entry.location.members[memberIndex];
759
+ await mkdir(path.dirname(location.nativePath), {recursive: true});
760
+ await rename(path.join(entry.attempt, 'parts', `${memberIndex}.part`), location.nativePath);
761
+ finished.add(memberIndex);
762
+ state.completedMembers = [...finished];
763
+ completed = finished.size;
764
+ await save();
765
+ try { await unlink(path.join(entry.attempt, 'parts', `${memberIndex}.previous.part`)); }
766
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
403
767
  progress('download', memberIndex);
404
768
  }
769
+ entry.archivePart = async function prepareArchivePart(memberIndex) {
770
+ const partial = path.join(entry.attempt, 'parts', `${memberIndex}.part`);
771
+ try {
772
+ await stat(partial);
773
+ await retainRestartedPart(entry.attempt, memberIndex);
774
+ progress('restart', memberIndex, false, {reason: 'The selected TAR segment must be replayed to recover its member framing.'});
775
+ } catch (error) { if (error.code !== 'ENOENT') throw error; }
776
+ return partial;
777
+ };
778
+ if (completed) progress('reuse');
405
779
  const jobs = members.map(function ordinaryJob(member, memberIndex) {
406
- if (member.archiveMember !== undefined) return null;
780
+ if (member.archiveMember !== undefined || finished.has(memberIndex)) return null;
407
781
  return async function downloadMember() {
408
- let handle;
409
- let response;
410
- let downloadError;
411
- const failures = [];
412
- let bodyOwned = false;
413
- try {
414
- signal.throwIfAborted();
415
- const nativePath = entry.location.members[memberIndex].nativePath;
416
- await mkdir(path.dirname(nativePath), {recursive: true});
417
- handle = await open(nativePath, 'wx');
418
- signal.throwIfAborted();
419
- progress('download', memberIndex);
420
- response = await fetchModelResponse(member.url, signal);
421
- bodyOwned = true;
422
- for await (const content of response.body) {
423
- signal.throwIfAborted();
424
- await handle.appendFile(content);
425
- progress('download', memberIndex, true);
426
- }
427
- signal.throwIfAborted();
428
- } catch (error) {
429
- downloadError = error;
430
- failures.push(error);
431
- firstFailure ??= error;
432
- controller.abort(error);
433
- }
434
- if (response?.body && !bodyOwned && !response.bodyUsed && !response.body.locked) {
435
- try { await response.body.cancel(downloadError); } catch (error) { if (error !== downloadError) failures.push(error); }
436
- }
437
- if (handle) {
438
- try { await handle.close(); } catch (error) { failures.push(error); }
439
- }
440
- if (failures.length) {
441
- const error = failures.length === 1 ? failures[0] : new AggregateError(failures, 'Downloading a model member and closing its resources failed.');
442
- firstFailure ??= error;
443
- controller.abort(error);
444
- throw error;
445
- }
446
- completeMember(memberIndex);
782
+ try { await downloadNativeMember(entry, memberIndex, state, save, progress, completeMember, noteFailure); }
783
+ catch (error) { noteFailure(error, 'failed'); throw error; }
447
784
  };
448
785
  }).filter(Boolean);
449
786
  let archiveIndex = members.findIndex(function archiveMember(member) { return member.archiveMember !== undefined; });
450
787
  for (const archive of entry.archives) {
451
788
  const selected = new Map();
789
+ const archiveMembers = [];
452
790
  for (const member of archive.members) {
791
+ const memberIndex = archiveIndex++;
792
+ archiveMembers.push(memberIndex);
453
793
  const name = tarName(member.member);
454
794
  if (!selected.has(name)) selected.set(name, []);
455
- selected.get(name).push(archiveIndex++);
795
+ selected.get(name).push(memberIndex);
456
796
  }
797
+ if (!selected.size) continue;
798
+ if (archiveMembers.every(function confirmed(index) { return finished.has(index) && completedArchiveMembers.has(index); })) continue;
799
+ for (const memberIndex of archiveMembers) completedArchiveMembers.delete(memberIndex);
457
800
  jobs.push(async function downloadArchiveMembers() {
458
801
  const found = new Set();
459
802
  try {
803
+ state.completedArchiveMembers = [...completedArchiveMembers];
804
+ await save();
460
805
  for (const range of archive.ranges ?? [undefined]) {
461
806
  signal.throwIfAborted();
462
807
  const response = await fetchModelResponse(archive.url, signal, range);
@@ -471,9 +816,13 @@ async function acquireNativeMembers(entry) {
471
816
  error.members = missing;
472
817
  throw error;
473
818
  }
819
+ // A file written before a later TAR occurrence is retained
820
+ // on interruption, but only a complete pass confirms reuse.
821
+ for (const memberIndex of archiveMembers) completedArchiveMembers.add(memberIndex);
822
+ state.completedArchiveMembers = [...completedArchiveMembers];
823
+ await save();
474
824
  } catch (error) {
475
- firstFailure ??= error;
476
- controller.abort(error);
825
+ noteFailure(error, error.code?.startsWith('MODEL_ASSET_ARCHIVE_') ? 'failed' : 'interrupted');
477
826
  throw error;
478
827
  }
479
828
  });
@@ -485,27 +834,41 @@ async function acquireNativeMembers(entry) {
485
834
  await jobs[nextJob++]();
486
835
  }
487
836
  }));
488
- const failures = results.filter(function rejected(result) { return result.status === 'rejected'; })
489
- .map(function reason(result) { return result.reason; });
837
+ const failures = [...new Set(results.filter(function rejected(result) { return result.status === 'rejected'; })
838
+ .map(function reason(result) { return result.reason; }))];
490
839
  if (failures.length > 1) throw new AggregateError(failures, 'Downloading model asset members failed.', {cause: firstFailure});
491
840
  if (failures.length) throw failures[0];
492
841
  signal.throwIfAborted();
493
842
  progress('complete');
494
843
  // The record describes a completed acquisition, not content identity.
495
844
  // Publish it after every stream and file close, leaving older entries alone.
496
- const pending = path.join(attempt, 'acquisition.pending.json');
497
- await writeFile(pending, JSON.stringify({members, order: ++nativeAcquisitionOrder}), {encoding: 'utf8', flag: 'wx', signal});
845
+ await save();
498
846
  signal.throwIfAborted();
499
- await rename(pending, path.join(attempt, 'acquisition.json'));
500
- committed = true;
847
+ await saveAcquisitionState(entry.attempt, 'acquisition.json', {members, order: state.order, refresh: state.refresh});
848
+ clearInterval(heartbeat);
501
849
  progress('ready');
850
+ await save();
502
851
  return entry.location;
503
852
  } catch (error) {
504
- if (attempt && !committed) {
505
- try { await rm(attempt, {recursive: true}); }
506
- catch (cleanupError) { throw new AggregateError([error, cleanupError], 'Preparing native model assets failed and cleanup failed.'); }
853
+ acquisitionError = error;
854
+ clearInterval(heartbeat);
855
+ progress(failureState ?? (signal.aborted ? 'cancelled' : 'failed'), undefined, false, {error: serializeCoreError(error)});
856
+ if (state) {
857
+ try { await save(); }
858
+ catch (stateError) {
859
+ acquisitionError = new AggregateError([error, stateError], 'Preparing model assets and retaining their interrupted state failed.');
860
+ throw acquisitionError;
861
+ }
507
862
  }
508
863
  throw error;
864
+ } finally {
865
+ clearInterval(heartbeat);
866
+ await stateTail;
867
+ try { await releaseOwner?.(); }
868
+ catch (error) {
869
+ if (acquisitionError) throw new AggregateError([acquisitionError, error], 'Preparing model assets and releasing their acquisition owner failed.');
870
+ throw error;
871
+ }
509
872
  }
510
873
  }
511
874
 
@@ -591,7 +954,8 @@ export function createModelAssetService({appRoot = process.cwd()} = {}) {
591
954
  return {path: member.path, nativePath: member.nativePath};
592
955
  }),
593
956
  preparationOwned: record.preparationOwned, uses: record.uses.size,
594
- error: record.error ? serializeCoreError(record.error) : null
957
+ error: record.error ? serializeCoreError(record.error) : null,
958
+ ...(record.progress ? {progress: record.progress} : {})
595
959
  };
596
960
  }
597
961
 
@@ -777,6 +1141,9 @@ export function createModelAssetService({appRoot = process.cwd()} = {}) {
777
1141
  record.directory = location.directory;
778
1142
  record.members = location.members;
779
1143
  }
1144
+ record.progress = value;
1145
+ record.state = value.state;
1146
+ publish(record);
780
1147
  await onProgress?.(value);
781
1148
  }});
782
1149
  });