arcane-os 0.96.0 → 0.97.0

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
+ }
214
332
  }
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};
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;
378
+ }
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;
@@ -336,7 +512,8 @@ async function extractTarRange(response, range, selected, found, entry, progress
336
512
  for (const memberIndex of memberIndexes) {
337
513
  const location = entry.location.members[memberIndex];
338
514
  await mkdir(path.dirname(location.nativePath), {recursive: true});
339
- handles.push(await open(location.nativePath, 'wx'));
515
+ const partial = await entry.archivePart(memberIndex);
516
+ handles.push(await open(partial, 'wx'));
340
517
  progress('download', memberIndex);
341
518
  }
342
519
  await consume(length, async function originalMemberContent(content) {
@@ -352,7 +529,7 @@ async function extractTarRange(response, range, selected, found, entry, progress
352
529
  if (errors.length) throw new AggregateError(errors, 'Extracting a model member and closing its files failed.');
353
530
  for (const memberIndex of memberIndexes) {
354
531
  found.add(memberIndex);
355
- completeMember(memberIndex);
532
+ await completeMember(memberIndex);
356
533
  }
357
534
  } else await consume(length);
358
535
  await consume((512 - length % 512) % 512);
@@ -365,95 +542,258 @@ async function extractTarRange(response, range, selected, found, entry, progress
365
542
  if (operationError) throw operationError;
366
543
  }
367
544
 
545
+ async function downloadNativeMember(entry, memberIndex, state, save, progress, completeMember, noteFailure) {
546
+ const member = entry.members[memberIndex];
547
+ const {signal} = entry.controller;
548
+ const partial = path.join(entry.attempt, 'parts', `${memberIndex}.part`);
549
+ let offset = 0;
550
+ try { offset = (await stat(partial)).size; }
551
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
552
+ const previous = state.transports[memberIndex];
553
+ let response;
554
+ let reader;
555
+ let handle;
556
+ let cancellation;
557
+ let ended = false;
558
+ let operationError;
559
+ let remaining;
560
+
561
+ async function restart(reason) {
562
+ progress('restart', memberIndex, false, {reason});
563
+ await retainRestartedPart(entry.attempt, memberIndex);
564
+ offset = 0;
565
+ }
566
+ async function request(headers) {
567
+ try { return await fetch(member.url, {signal, headers}); }
568
+ catch (error) { noteFailure(error, 'interrupted'); throw error; }
569
+ }
570
+ function cancelReader() {
571
+ if (!reader || ended) return;
572
+ cancellation ??= reader.cancel(signal.reason);
573
+ cancellation.catch(function observeReaderCancellation() {});
574
+ }
575
+ try {
576
+ signal.throwIfAborted();
577
+ if (offset && previous?.encoding && previous.encoding !== 'identity') await restart('The previous response used encoded HTTP content and cannot be appended by Range.');
578
+ const validator = previous?.etag && !previous.etag.startsWith('W/') ? previous.etag : previous?.lastModified;
579
+ if (offset && !validator) await restart('The source supplied no HTTP validator for continuing its earlier response.');
580
+ const headers = {'Accept-Encoding': 'identity'};
581
+ if (offset) {
582
+ headers.Range = `bytes=${offset}-`;
583
+ headers['If-Range'] = validator;
584
+ progress('resume', memberIndex);
585
+ } else progress('download', memberIndex);
586
+ response = await request(headers);
587
+ signal.throwIfAborted();
588
+ if (offset && response.status === 416) {
589
+ await response.body?.cancel();
590
+ response = undefined;
591
+ await restart('The server no longer accepts this saved HTTP range.');
592
+ response = await request({'Accept-Encoding': 'identity'});
593
+ }
594
+ if (!response.ok) {
595
+ const error = failure('MODEL_ASSET_DOWNLOAD_FAILED', `Model asset request failed: ${response.status} ${response.statusText}`);
596
+ error.url = member.url;
597
+ error.status = response.status;
598
+ error.response = await response.text();
599
+ noteFailure(error, response.status >= 500 || response.status === 408 || response.status === 429 ? 'interrupted' : 'failed');
600
+ throw error;
601
+ }
602
+ if (!response.body) throw failure('MODEL_ASSET_DOWNLOAD_FAILED', `Model asset response has no content stream: ${member.url}`);
603
+ if (offset && response.status === 200) await restart('The server returned the complete file instead of the saved HTTP range.');
604
+ if (response.status === 206) {
605
+ const range = /^bytes (\d+)-(\d+)\/(\d+)$/.exec(response.headers.get('content-range') ?? '');
606
+ const encoding = response.headers.get('content-encoding');
607
+ const validatorHeader = previous?.etag && !previous.etag.startsWith('W/') ? 'etag' : 'last-modified';
608
+ const currentValidator = response.headers.get(validatorHeader);
609
+ const changed = currentValidator && currentValidator !== validator;
610
+ if (!offset || !range || Number(range[1]) !== offset || Number(range[2]) < offset
611
+ || Number(range[2]) + 1 !== Number(range[3]) || (encoding && encoding !== 'identity') || changed) {
612
+ const error = failure('MODEL_ASSET_DOWNLOAD_RANGE_FAILED', 'The server did not return the requested continuation of this model file.');
613
+ error.url = member.url;
614
+ error.status = response.status;
615
+ throw error;
616
+ }
617
+ // Content-Range framing is private to this HTTP transfer. It never
618
+ // participates in model identity, admission or user-facing progress.
619
+ remaining = Number(range[2]) - Number(range[1]) + 1;
620
+ }
621
+ state.transports[memberIndex] = {
622
+ etag: response.headers.get('etag'), lastModified: response.headers.get('last-modified'),
623
+ encoding: response.headers.get('content-encoding')
624
+ };
625
+ await save();
626
+ handle = await open(partial, 'a');
627
+ reader = response.body.getReader();
628
+ signal.addEventListener('abort', cancelReader, {once: true});
629
+ if (signal.aborted) cancelReader();
630
+ while (true) {
631
+ signal.throwIfAborted();
632
+ let chunk;
633
+ try { chunk = await reader.read(); }
634
+ catch (error) { noteFailure(error, 'interrupted'); throw error; }
635
+ signal.throwIfAborted();
636
+ if (chunk.done) { ended = true; break; }
637
+ if (remaining !== undefined) {
638
+ remaining -= chunk.value.byteLength;
639
+ if (remaining < 0) throw failure('MODEL_ASSET_DOWNLOAD_RANGE_FAILED', 'The server exceeded its HTTP Content-Range.');
640
+ }
641
+ await handle.appendFile(chunk.value);
642
+ progress('download', memberIndex, true);
643
+ }
644
+ if (remaining !== undefined && remaining !== 0) throw failure('MODEL_ASSET_DOWNLOAD_RANGE_FAILED', 'The server ended before its HTTP Content-Range.');
645
+ signal.throwIfAborted();
646
+ } catch (error) { operationError = error; noteFailure(error, 'failed'); }
647
+ signal.removeEventListener('abort', cancelReader);
648
+ try {
649
+ if (reader && !ended) { cancellation ??= reader.cancel(operationError); await cancellation; }
650
+ else if (!reader && response?.body && !response.bodyUsed && !response.body.locked) await response.body.cancel(operationError);
651
+ } catch (error) {
652
+ if (error !== operationError) operationError = operationError ? new AggregateError([operationError, error], 'Downloading a model member and closing its response failed.') : error;
653
+ }
654
+ try { reader?.releaseLock(); }
655
+ catch (error) { operationError = operationError ? new AggregateError([operationError, error], 'Downloading a model member and releasing its reader failed.') : error; }
656
+ try { await handle?.close(); }
657
+ catch (error) { operationError = operationError ? new AggregateError([operationError, error], 'Downloading a model member and closing its file failed.') : error; }
658
+ if (operationError) { noteFailure(operationError, 'failed'); throw operationError; }
659
+ await completeMember(memberIndex);
660
+ }
661
+
368
662
  async function acquireNativeMembers(entry) {
369
- const {directory, members, controller} = entry;
663
+ const {members, controller} = entry;
370
664
  const {signal} = controller;
371
- let attempt;
372
665
  let completed = 0;
373
- let committed = false;
374
666
  let firstFailure;
667
+ let failureState;
668
+ let releaseOwner;
669
+ let state;
670
+ let stateTail = Promise.resolve();
671
+ let heartbeat;
672
+ let acquisitionError;
673
+ let lastActivity = Date.now();
674
+ entry.activity = function receivedTransportContent() { lastActivity = Date.now(); };
375
675
  const activity = new Map();
376
- function progress(phase, memberIndex, repeatedActivity = false) {
676
+ function noteFailure(error, selectedState) {
677
+ if (firstFailure || signal.aborted) return;
678
+ firstFailure = error;
679
+ failureState = selectedState;
680
+ controller.abort(error);
681
+ }
682
+ function save() {
683
+ const next = stateTail.then(function saveTransferState() { return saveAcquisitionState(entry.attempt, 'transfer.json', state); });
684
+ stateTail = next.catch(function observeStateFailure(error) { noteFailure(error, 'failed'); });
685
+ return next;
686
+ }
687
+ function progress(phase, memberIndex, repeatedActivity = false, details = {}) {
688
+ if (phase === 'download') lastActivity = Date.now();
377
689
  if (repeatedActivity) {
378
690
  const now = Date.now();
379
691
  if (now - (activity.get(memberIndex) ?? 0) < 1000) return;
380
692
  activity.set(memberIndex, now);
381
693
  }
382
- entry.publish({phase, completed, total: members.length, unit: 'files',
383
- ...(memberIndex === undefined ? {} : {memberIndex, path: members[memberIndex].path})});
694
+ const selectedState = details.state ?? ({waiting: 'waiting', stalled: 'stalled', interrupted: 'interrupted',
695
+ cancelled: 'cancelled', failed: 'failed', ready: 'ready'})[phase] ?? 'downloading';
696
+ const messages = {
697
+ waiting: 'Another application is preparing these model files. Waiting for its acquisition.',
698
+ downloading: 'Downloading model files.', stalled: 'The model download is still open; no recent data has arrived.',
699
+ interrupted: 'The model download was interrupted. Completed files and resumable data were kept.',
700
+ cancelled: 'The model download was cancelled. Completed files and resumable data were kept.',
701
+ failed: 'The model download failed. Existing files and resumable data were kept.', ready: 'Model files are ready.'
702
+ };
703
+ const notification = {phase, state: selectedState, message: phase === 'resume' ? 'Resuming the saved model download.'
704
+ : phase === 'restart' ? 'Restarting this model file because its saved HTTP continuation is unavailable. Earlier partial data was retained.'
705
+ : phase === 'reuse' ? 'Reusing downloaded model files.' : messages[selectedState],
706
+ completed, total: members.length, unit: 'files',
707
+ ...(memberIndex === undefined ? {} : {memberIndex, path: members[memberIndex].path}), ...details};
708
+ if (['interrupted', 'cancelled', 'failed'].includes(selectedState)) notification.recovery = 'Retry to reuse completed files and continue saved downloads.';
709
+ if (selectedState === 'stalled') notification.recovery = 'Wait for the source to respond, or cancel and retry later.';
710
+ if (state) state.progress = notification;
711
+ entry.publish(notification);
384
712
  }
385
713
  try {
386
714
  signal.throwIfAborted();
387
- const stored = await storedAcquisition(directory, members, signal);
388
- if (stored && !entry.refresh) {
389
- entry.location = stored;
715
+ const selected = await selectNativeAcquisition(entry, progress);
716
+ entry.attempt = selected.root;
717
+ entry.location = {directory: selected.directory, members: selected.members};
718
+ if (selected.complete) {
390
719
  completed = members.length;
391
720
  progress('reuse');
392
721
  progress('ready');
393
- return stored;
722
+ return entry.location;
394
723
  }
395
724
  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); })};
725
+ releaseOwner = await acquireDirectoryOwner(selected.root, '.transfer-owner', signal, async function waitingForTransfer() {
726
+ try {
727
+ const observed = JSON.parse(await readFile(path.join(selected.root, 'transfer.json'), 'utf8'));
728
+ completed = observed.completedMembers?.length ?? 0;
729
+ progress('waiting', undefined, false, {activity: observed.progress});
730
+ } catch (error) { if (error.code !== 'ENOENT') throw error; progress('waiting'); }
731
+ });
732
+ const current = (await storedAcquisitions(entry.directory, members, signal)).find(function ownedAttempt(candidate) { return candidate.root === selected.root; });
733
+ if (current?.complete) {
734
+ completed = members.length;
735
+ progress('reuse');
736
+ progress('ready');
737
+ return entry.location;
738
+ }
739
+ try { state = JSON.parse(await readFile(path.join(selected.root, 'transfer.json'), 'utf8')); }
740
+ catch (error) {
741
+ if (error.code !== 'ENOENT') throw error;
742
+ state = {members, order: selected.order, refresh: selected.refresh, transports: {}};
743
+ }
744
+ state.transports ??= {};
745
+ state.completedMembers = current?.completedMembers ?? [];
746
+ const finished = new Set(state.completedMembers);
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();
452
789
  for (const member of archive.members) {
790
+ const memberIndex = archiveIndex++;
791
+ if (finished.has(memberIndex)) continue;
453
792
  const name = tarName(member.member);
454
793
  if (!selected.has(name)) selected.set(name, []);
455
- selected.get(name).push(archiveIndex++);
794
+ selected.get(name).push(memberIndex);
456
795
  }
796
+ if (!selected.size) continue;
457
797
  jobs.push(async function downloadArchiveMembers() {
458
798
  const found = new Set();
459
799
  try {
@@ -472,8 +812,7 @@ async function acquireNativeMembers(entry) {
472
812
  throw error;
473
813
  }
474
814
  } catch (error) {
475
- firstFailure ??= error;
476
- controller.abort(error);
815
+ noteFailure(error, error.code?.startsWith('MODEL_ASSET_ARCHIVE_') ? 'failed' : 'interrupted');
477
816
  throw error;
478
817
  }
479
818
  });
@@ -485,27 +824,41 @@ async function acquireNativeMembers(entry) {
485
824
  await jobs[nextJob++]();
486
825
  }
487
826
  }));
488
- const failures = results.filter(function rejected(result) { return result.status === 'rejected'; })
489
- .map(function reason(result) { return result.reason; });
827
+ const failures = [...new Set(results.filter(function rejected(result) { return result.status === 'rejected'; })
828
+ .map(function reason(result) { return result.reason; }))];
490
829
  if (failures.length > 1) throw new AggregateError(failures, 'Downloading model asset members failed.', {cause: firstFailure});
491
830
  if (failures.length) throw failures[0];
492
831
  signal.throwIfAborted();
493
832
  progress('complete');
494
833
  // The record describes a completed acquisition, not content identity.
495
834
  // 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});
835
+ await save();
498
836
  signal.throwIfAborted();
499
- await rename(pending, path.join(attempt, 'acquisition.json'));
500
- committed = true;
837
+ await saveAcquisitionState(entry.attempt, 'acquisition.json', {members, order: state.order, refresh: state.refresh});
838
+ clearInterval(heartbeat);
501
839
  progress('ready');
840
+ await save();
502
841
  return entry.location;
503
842
  } 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.'); }
843
+ acquisitionError = error;
844
+ clearInterval(heartbeat);
845
+ progress(failureState ?? (signal.aborted ? 'cancelled' : 'failed'), undefined, false, {error: serializeCoreError(error)});
846
+ if (state) {
847
+ try { await save(); }
848
+ catch (stateError) {
849
+ acquisitionError = new AggregateError([error, stateError], 'Preparing model assets and retaining their interrupted state failed.');
850
+ throw acquisitionError;
851
+ }
507
852
  }
508
853
  throw error;
854
+ } finally {
855
+ clearInterval(heartbeat);
856
+ await stateTail;
857
+ try { await releaseOwner?.(); }
858
+ catch (error) {
859
+ if (acquisitionError) throw new AggregateError([acquisitionError, error], 'Preparing model assets and releasing their acquisition owner failed.');
860
+ throw error;
861
+ }
509
862
  }
510
863
  }
511
864
 
@@ -591,7 +944,8 @@ export function createModelAssetService({appRoot = process.cwd()} = {}) {
591
944
  return {path: member.path, nativePath: member.nativePath};
592
945
  }),
593
946
  preparationOwned: record.preparationOwned, uses: record.uses.size,
594
- error: record.error ? serializeCoreError(record.error) : null
947
+ error: record.error ? serializeCoreError(record.error) : null,
948
+ ...(record.progress ? {progress: record.progress} : {})
595
949
  };
596
950
  }
597
951
 
@@ -777,6 +1131,9 @@ export function createModelAssetService({appRoot = process.cwd()} = {}) {
777
1131
  record.directory = location.directory;
778
1132
  record.members = location.members;
779
1133
  }
1134
+ record.progress = value;
1135
+ record.state = value.state;
1136
+ publish(record);
780
1137
  await onProgress?.(value);
781
1138
  }});
782
1139
  });