OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay commits / 2fa58c1

fix: restore orphaned lifecycle/gateway features; unblock the unit suite
The unit suite aborted at test 727 on a hard TypeError — AppLifecycle had lost an entire seed-admission subsystem that its tests, and the storage contracts around it, still described. Clearing that crash made another ~2800 tests reachable and surfaced three defects that were live. Public read plane: - HyperGateway.handle() dereferenced the optional exact-app `context` without a null guard while gateway-server serves /v1/hyper/:key with no context, so every non-HEAD file GET returned 502. - The storage-lease wrapper re-entered handle(req, res) without forwarding `context`, silently disabling the per-window egress byte budget and the X-Hive-App-Key / X-Hive-Byte-Mode headers on exact app-origin responses. Storage durability: - A seed reported success when its durable registry write failed, because AppRegistry.set() persists fire-and-forget. Persist is now explicit and awaited, with rollback to the pre-seed entry. - unseedApp takes the same per-drive storage lease the gateway holds, so a durable retirement cannot start while a response still streams from pinned core sessions. The lease is bypassed once the authority stops accepting mutations — stop() retires drives after that point and must still be able to close its own. - Concurrent shutdown+forget retired a drive twice; both callers now share one coalesced retirement owner. - Mixed-case app keys missed the live-entry check and seeded a duplicate drive under a different mutation key. - Manifest indexing read the live drive rather than the proven version, and its 5s timeout left the underlying get in flight; it now reads { wait: false } from a proof-pinned checkout that is always closed. Restored: - _queueSeedAdmission: the old spin-wait mutex could not cancel, so a timed-out or aborted caller still mutated state when its turn came. - getDriveSize authoritative/pinned-proof mode: length and byteLength are separate getters, so an append between them produced a tuple that never existed and under-counted the drive. - Ordered auxiliary share-bundle teardown, which had no production caller — snapshot sessions and download trackers leaked on every PVSS read. - Exact discovery-handle and download-snapshot-core release on retirement. - hiverelay-forward transport advertisement, without which the client path resolver could never select the source-ip-hidden tier. Seed and repair now fail before Corestore/Hyperdrive adoption when durable writes are unavailable, and seeding before recovery inventories seal reports STORAGE_RECOVERY_INVENTORY_PENDING. Suite: 727 (aborted) -> 3510 tests, 3 known failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2fa58c1ee333aeb4883cf481e51ccd9b67f03802
by bigdestiny2 <33146784+bigdestiny2@users.noreply.github.com> · 2026-07-28T00:19:20.000Z

Changed files (8) +817 -97

packages/core/core/capability-doc.js +5 -0
……@@ -182,6 +182,11 @@ export function buildCapabilityDoc (opts = {}) {
182182 if (relay && relay.dhtRelayWs && relay.dhtRelayWs.running) transports.push('dht-relay-ws')
183183 if (relay && relay.torTransport && relay.torTransport.running) transports.push('tor')
184184 if (relay && relay.holesailTransport) transports.push('holesail')
185 // Opt-in forward transport (`config.forwardRelay.enabled`). Without this the
186 // client-side path resolver can never select PATH_FORWARD_RELAY — it gates on
187 // supported_transports containing 'hiverelay-forward' — so the shipped
188 // source-ip-hidden / hidden-1hop privacy tier was unreachable end to end.
189 if (relay && relay.forwardRelay && relay.forwardRelay.enabled) transports.push('hiverelay-forward')
185190
186191 // Federation — snapshot is a pure read, no I/O.
187192 let federation = null
packages/core/core/relay-node/app-lifecycle.js +550 -92
……@@ -17,6 +17,19 @@ import {
1717 normalizeStorageClass
1818} from '../constants.js'
1919
20function seedAdmissionTimeoutError (timeoutMs) {
21 const err = new Error(`Seed admission timed out after ${timeoutMs}ms`)
22 err.code = 'STORAGE_SEED_ADMISSION_TIMEOUT'
23 return err
24}
25
26function seedAdmissionAbortError () {
27 const err = new Error('Seed admission aborted')
28 err.name = 'AbortError'
29 err.code = 'ABORT_ERR'
30 return err
31}
32
2033/**
2134 * AppLifecycle — owns seeding, unseeding, and manifest indexing for a RelayNode.
2235 *
……@@ -30,6 +43,10 @@ export class AppLifecycle extends EventEmitter {
3043 super()
3144 this.node = node
3245 this._seedMutex = false
46 // Serialization tail for seed admission. Callers queue behind it via
47 // _queueSeedAdmission; the tail itself never rejects, so stop paths can
48 // `await lifecycle._seedTail` to know the chain has drained.
49 this._seedTail = Promise.resolve()
3350 // Drives whose live resources (ranges, drive, bridge) have been settled
3451 // but whose durable delete has not yet completed. On failure the entry is
3552 // restored to the registry and parked here so a retry can finish the
……@@ -38,6 +55,10 @@ export class AppLifecycle extends EventEmitter {
3855 // Active auxiliary share-bundle fetches. unseedApp drains these before
3956 // releasing drive debt so a concurrent fetch cannot race with teardown.
4057 this._auxShareBundleResources = new Set()
58 // Drive keys whose storage lease this lifecycle already holds, so a
59 // re-entrant call (seed → evict → unseed) doesn't deadlock on the
60 // non-reentrant per-key mutation lock.
61 this._heldDriveLeases = new Set()
4162 }
4263
4364 /**
……@@ -48,6 +69,82 @@ export class AppLifecycle extends EventEmitter {
4869 return this.node.appRegistry.apps
4970 }
5071
72 /**
73 * Serialize a seed-admission closure behind every earlier one.
74 *
75 * Why a queue and not a spin-wait mutex: a caller that gives up (timeout)
76 * or a node that is shutting down (scope abort) must ALSO cancel the work
77 * it queued. The old `while (this._seedMutex) await sleep(50)` loop could
78 * not do that — the waiter's closure still ran when the mutex freed, so a
79 * seed that had already reported failure to its caller went on to mutate
80 * the registry and storage-admission ledger afterwards.
81 *
82 * Cancellation only applies while a closure is WAITING. Once it starts it
83 * owns the admission slot and runs to completion; aborting mid-seed would
84 * strand a half-registered drive, which is exactly what the retirement
85 * owner in unseedApp exists to avoid.
86 *
87 * @param {() => Promise<any>} run the admission-critical closure
88 * @returns {Promise<any>} the closure's result, or a rejection with code
89 * `STORAGE_SEED_ADMISSION_TIMEOUT` / `ABORT_ERR` if it never ran.
90 */
91 _queueSeedAdmission (run) {
92 const node = this.node || {}
93 const scope = node._scope || null
94 const signal = scope && scope.signal ? scope.signal : null
95 const configured = Number(node.config && node.config.seedAdmissionTimeoutMs)
96 const timeoutMs = Number.isFinite(configured) && configured > 0 ? configured : 0
97
98 const gate = { started: false, cancelled: false }
99 let resolveCaller = null
100 let rejectCaller = null
101 const caller = new Promise((resolve, reject) => {
102 resolveCaller = resolve
103 rejectCaller = reject
104 })
105
106 let timer = null
107 let onAbort = null
108 const disarm = () => {
109 if (timer) { clearTimeout(timer); timer = null }
110 if (onAbort && signal) { signal.removeEventListener('abort', onAbort); onAbort = null }
111 }
112 const cancel = (err) => {
113 if (gate.started || gate.cancelled) return
114 gate.cancelled = true
115 disarm()
116 rejectCaller(err)
117 }
118
119 const previous = this._seedTail || Promise.resolve()
120 this._seedTail = previous.then(async () => {
121 if (gate.cancelled) return
122 gate.started = true
123 disarm()
124 try {
125 resolveCaller(await run())
126 } catch (err) {
127 rejectCaller(err)
128 }
129 })
130
131 if (signal && signal.aborted) {
132 cancel(seedAdmissionAbortError())
133 } else {
134 // Deliberately NOT unref'd: a caller is blocked on this timeout, so the
135 // event loop must stay alive long enough to deliver it. It is cleared as
136 // soon as the closure starts or the wait is cancelled, so it can only
137 // hold the process for at most seedAdmissionTimeoutMs.
138 if (timeoutMs > 0) timer = setTimeout(() => cancel(seedAdmissionTimeoutError(timeoutMs)), timeoutMs)
139 if (signal) {
140 onAbort = () => cancel(seedAdmissionAbortError())
141 signal.addEventListener('abort', onAbort, { once: true })
142 }
143 }
144
145 return caller
146 }
147
51148 /**
52149 * Load the durable drive registry (and run the one-shot seeded-apps.json
53150 * migration when empty). Does NOT re-seed drives — callers that need the
……@@ -188,8 +285,24 @@ export class AppLifecycle extends EventEmitter {
188285
189286 async seedApp (appKeyHex, opts = {}) {
190287 const node = this.node
288 // Recovery inventories have not sealed, so the relay does not yet know
289 // what it already owns — admitting new content here could double-count
290 // storage against a commitment the recovery pass is about to adopt.
291 // Checked before every other gate (including "seeding not enabled") so
292 // the caller gets the specific, retryable cause rather than a generic
293 // refusal that hides the fact that startup simply hasn't finished.
294 if (node._storageIngressReady === false) {
295 const err = new Error('storage recovery inventory has not sealed')
296 err.code = 'STORAGE_RECOVERY_INVENTORY_PENDING'
297 throw err
298 }
191299 if (!node.seeder) throw new Error('Seeding not enabled')
192300 if (!isValidHexKey(appKeyHex)) throw new Error('Invalid app key: must be 64 hex characters')
301 // Canonicalize before ANYTHING keys off it. AppRegistry stores lowercase,
302 // so a mixed-case caller otherwise misses the live entry, seeds a second
303 // drive for the same content, and serializes under a different
304 // storage-admission mutation key than the call it is racing.
305 appKeyHex = appKeyHex.toLowerCase()
193306
194307 const contentType = normalizeContentType(opts.type, 'app')
195308 const blind = opts.blind === true
……@@ -254,9 +367,97 @@ export class AppLifecycle extends EventEmitter {
254367 // those re-pins now via _reconcileSeedOptsOnRepin: cap-up + unanchored
255368 // retriggers replication; cap-down emits a warning; same/missing is
256369 // a no-op (matches prior behavior).
370 const mutationKey = `drive:${appKeyHex}`
371 return this._withDriveLease(appKeyHex, () => this._seedAppAdmitted(
372 appKeyHex, normalizedOpts, contentType, parentKey, mountPath, privacyTier, mutationKey
373 ))
374 }
375
376 /**
377 * Run `run` while holding this drive's per-key storage lease.
378 *
379 * The same lease is taken by seedApp, unseedApp, the PVSS auxiliary fetch,
380 * and HyperGateway's HTTP reads, so a durable retirement cannot begin while
381 * a stalled HTTP response is still reading pinned core sessions, and a new
382 * read cannot enter after retirement starts.
383 *
384 * runKeyMutation is NOT re-entrant, so a held key is tracked and re-entered
385 * directly — a seed that evicts and unseeds the same key would otherwise
386 * wait on a lock it is itself holding.
387 */
388 /**
389 * True when the registry can accept a durable write right now. Non-throwing
390 * probe used to stop storage-PRODUCING work (seed, repair) early while still
391 * allowing serve-only paths to run under a read-only physical authority.
392 */
393 _durableWritesAvailable () {
394 const registry = this.node && this.node.appRegistry
395 if (!registry || typeof registry.assertDurableWritesAvailable !== 'function') return true
396 try {
397 registry.assertDurableWritesAvailable()
398 return true
399 } catch (_) {
400 return false
401 }
402 }
403
404 _withDriveLease (appKeyHex, run, opts = {}) {
405 const admission = this.node && this.node.storageAdmission
406 if (!admission || typeof admission.runKeyMutation !== 'function') return run()
407 if (this._heldDriveLeases.has(appKeyHex)) return run()
408 // stop() retires every seeded drive AFTER the authority stops accepting
409 // mutations, and runKeyMutation fails closed in that state. Gating a
410 // retirement on it would leave shutdown unable to close its own drives.
411 // Seeds keep failing closed here — refusing new content while storage is
412 // stopping is the correct answer; refusing to release it is not.
413 if (opts.allowWhenStopping && typeof admission.mutationAdmission === 'function' &&
414 admission.mutationAdmission().allowed !== true) {
415 return run()
416 }
417 return admission.runKeyMutation(`drive:${appKeyHex}`, async () => {
418 this._heldDriveLeases.add(appKeyHex)
419 try {
420 return await run()
421 } finally {
422 this._heldDriveLeases.delete(appKeyHex)
423 }
424 })
425 }
426
427 /**
428 * seedApp's body, already serialized on this drive's storage-admission
429 * mutation key. Split out so the already-seeded fast path runs UNDER that
430 * lock: evaluating it outside lets two concurrent callers both conclude the
431 * drive needs seeding.
432 */
433 async _seedAppAdmitted (appKeyHex, normalizedOpts, contentType, parentKey, mountPath, privacyTier, mutationKey) {
434 const node = this.node
435
436 // Fail before ANY storage adoption. Seeding ends in a durable registry
437 // write, so if the physical authority cannot accept one there is no point
438 // opening a Corestore session, building a Hyperdrive, or taking a storage
439 // reservation first — each of those is a resource we would then have to
440 // unwind, and the reservation error (STORAGE_ADMISSION_BLOCKED) would
441 // mask the real cause.
442 if (node.appRegistry && typeof node.appRegistry.assertDurableWritesAvailable === 'function') {
443 node.appRegistry.assertDurableWritesAvailable()
444 }
445
257446 if (this.seededApps.has(appKeyHex)) {
258447 const existing = this.seededApps.get(appKeyHex)
259448 if (existing && existing.discoveryKey) {
449 // Returning alreadySeeded ACKNOWLEDGES durable state to the caller.
450 // That is only honest if the storage ledger holds a committed record
451 // for this drive. When admission cannot acknowledge, the live entry is
452 // stale relative to durable state (crash between the registry write
453 // and the ledger commit, or a fail-closed authority) — reconcile
454 // instead of reporting a success nothing durable backs.
455 if (node.storageAdmission && typeof node.storageAdmission.canAcknowledge === 'function' &&
456 !node.storageAdmission.canAcknowledge(mutationKey)) {
457 const err = new Error('seeded drive storage authority requires reconciliation')
458 err.code = 'STORAGE_RECONCILIATION_REQUIRED'
459 throw err
460 }
260461 const dkHex = typeof existing.discoveryKey === 'string'
261462 ? existing.discoveryKey
262463 : b4a.toString(existing.discoveryKey, 'hex')
……@@ -272,16 +473,17 @@ export class AppLifecycle extends EventEmitter {
272473 // else: placeholder entry from load() — fall through to seed properly.
273474 }
274475
275 // Acquire seed mutex to prevent concurrent eviction races
276 while (this._seedMutex) {
277 await new Promise(resolve => setTimeout(resolve, 50))
278 }
279 this._seedMutex = true
280 try {
281 return await this._seedAppInner(appKeyHex, normalizedOpts, contentType, parentKey, mountPath, privacyTier)
282 } finally {
283 this._seedMutex = false
284 }
476 // Serialize against concurrent eviction races. Queued (not yet started)
477 // admissions are cancellable by shutdown or by seedAdmissionTimeoutMs, so
478 // a caller that has already been told "no" never mutates state later.
479 return this._queueSeedAdmission(async () => {
480 this._seedMutex = true
481 try {
482 return await this._seedAppInner(appKeyHex, normalizedOpts, contentType, parentKey, mountPath, privacyTier)
483 } finally {
484 this._seedMutex = false
485 }
486 })
285487 }
286488
287489 async _seedAppInner (appKeyHex, opts, contentType, parentKey, mountPath, privacyTier) {
……@@ -314,6 +516,7 @@ export class AppLifecycle extends EventEmitter {
314516 const admissionCap = Number.isFinite(opts.maxStorage) && opts.maxStorage > 0
315517 ? Math.floor(opts.maxStorage)
316518 : null
519 let admissionReservation = null
317520 if (node.storageAdmission && typeof node.storageAdmission.reserve === 'function' && admissionCap !== null) {
318521 const reservation = node.storageAdmission.reserve(`drive:${appKeyHex}`, admissionCap)
319522 if (!reservation || reservation.allowed !== true) {
……@@ -322,53 +525,61 @@ export class AppLifecycle extends EventEmitter {
322525 err.storageAdmission = reservation
323526 throw err
324527 }
325 // Commit on successful seed (after drive creation); for now track it.
326 this._pendingAdmission = reservation
327 }
328
329 // Evict oldest app if storage capacity would be exceeded
330 if (node.config.enableEviction !== false && node.seeder && node.seeder.totalBytesStored >= node.config.maxStorageBytes && this.seededApps.size > 0) {
331 const TWENTY_FOUR_HOURS = 24 * 60 * 60 * 1000
332 let oldestKey = null
333 let oldestTime = Infinity
334
335 for (const [appKey, entry] of this.seededApps) {
336 if (entry.startedAt < oldestTime) {
337 oldestTime = entry.startedAt
338 oldestKey = appKey
528 // Held in the `reserved` state until the drive exists AND its registry
529 // entry is durably persisted, then committed below. EVERY exit path
530 // between here and that commit must roll it back, so the eviction check
531 // and the Hyperdrive construction moved inside the try below — both can
532 // throw, and a reservation abandoned in `reserved` state is not merely a
533 // leaked byte count: reserve() rejects the next attempt on this key with
534 // `storage-reservation-in-progress` and canAcknowledge() stays false, so
535 // the drive can never be re-seeded, re-pinned, or acknowledged again.
536 admissionReservation = reservation
537 }
538
539 let drive = null
540 try {
541 // Evict oldest app if storage capacity would be exceeded
542 if (node.config.enableEviction !== false && node.seeder && node.seeder.totalBytesStored >= node.config.maxStorageBytes && this.seededApps.size > 0) {
543 const TWENTY_FOUR_HOURS = 24 * 60 * 60 * 1000
544 let oldestKey = null
545 let oldestTime = Infinity
546
547 for (const [appKey, entry] of this.seededApps) {
548 if (entry.startedAt < oldestTime) {
549 oldestTime = entry.startedAt
550 oldestKey = appKey
551 }
339552 }
340 }
341553
342 const shouldEvict = oldestKey && (
343 (opts.replicationFactor && opts.replicationFactor > (this.seededApps.get(oldestKey)?.replicationFactor || 1)) ||
344 (Date.now() - oldestTime > TWENTY_FOUR_HOURS)
345 )
554 const shouldEvict = oldestKey && (
555 (opts.replicationFactor && opts.replicationFactor > (this.seededApps.get(oldestKey)?.replicationFactor || 1)) ||
556 (Date.now() - oldestTime > TWENTY_FOUR_HOURS)
557 )
346558
347 if (shouldEvict) {
348 await node._evictOldestApp()
349 } else {
350 throw new Error('Storage capacity exceeded and no eligible app to evict')
559 if (shouldEvict) {
560 await node._evictOldestApp()
561 } else {
562 throw new Error('Storage capacity exceeded and no eligible app to evict')
563 }
351564 }
352 }
353565
354 const publisherPubkey = opts.publisherPubkey
355 ? (typeof opts.publisherPubkey === 'string'
356 ? opts.publisherPubkey
357 : b4a.toString(opts.publisherPubkey, 'hex'))
358 : null
566 const publisherPubkey = opts.publisherPubkey
567 ? (typeof opts.publisherPubkey === 'string'
568 ? opts.publisherPubkey
569 : b4a.toString(opts.publisherPubkey, 'hex'))
570 : null
359571
360 const appKey = b4a.from(appKeyHex, 'hex')
361 // Use a per-app corestore session so drive.close() does NOT propagate to
362 // the shared root store. A session shares the same key-addressed hypercore
363 // objects (same _root.cores map, same key derivation for explicit keys) but
364 // its _close() only tears down this session's refs — the root store stays
365 // open for all other seeded drives. Without this, any unseed path (custody
366 // expiry, eviction, manual unseed) would call corestore.close() on the root
367 // store and wedge the entire relay until systemd restarted the process.
368 // See: .planning/debug/CAPTURED-TRACE-2026-05-18.md (root cause confirmed).
369 const drive = new Hyperdrive(node.store.session(), appKey)
572 const appKey = b4a.from(appKeyHex, 'hex')
573 // Use a per-app corestore session so drive.close() does NOT propagate to
574 // the shared root store. A session shares the same key-addressed hypercore
575 // objects (same _root.cores map, same key derivation for explicit keys) but
576 // its _close() only tears down this session's refs — the root store stays
577 // open for all other seeded drives. Without this, any unseed path (custody
578 // expiry, eviction, manual unseed) would call corestore.close() on the root
579 // store and wedge the entire relay until systemd restarted the process.
580 // See: .planning/debug/CAPTURED-TRACE-2026-05-18.md (root cause confirmed).
581 drive = new Hyperdrive(node.store.session(), appKey)
370582
371 try {
372583 // 2026-05-24: hard timeout on drive.ready(). On a drive whose
373584 // underlying hypercore is in a bad state (corrupted index, hung
374585 // session, etc.), ready() can hang indefinitely — and because
……@@ -446,6 +657,11 @@ export class AppLifecycle extends EventEmitter {
446657 ? Math.floor(opts.maxStorage)
447658 : null
448659
660 // Captured before the write so a failed durable persist can restore the
661 // exact pre-seed view (usually the null-discoveryKey placeholder that
662 // load() created), rather than leaving a half-seeded entry behind.
663 const previousEntry = node.appRegistry.get(appKeyHex) || null
664
449665 node.appRegistry.set(appKeyHex, {
450666 drive,
451667 discoveryKey,
……@@ -475,7 +691,38 @@ export class AppLifecycle extends EventEmitter {
475691 unseedFreezeMs,
476692 durability,
477693 maxStorage
478 })
694 }, { persist: false })
695
696 // Durability gate: persist EXPLICITLY and await it before reporting the
697 // seed as successful. AppRegistry.set() persists fire-and-forget, so a
698 // failed durable write used to be swallowed — seedApp returned a
699 // discoveryKey, the drive served traffic, and the entry simply was not
700 // there after the next restart. On failure, restore the pre-seed view
701 // and rethrow; the enclosing catch closes the drive.
702 if (typeof node.appRegistry.persistEntry === 'function') {
703 try {
704 await node.appRegistry.persistEntry(appKeyHex, { throwOnError: true })
705 } catch (err) {
706 if (previousEntry) node.appRegistry.set(appKeyHex, previousEntry, { persist: false })
707 else node.appRegistry.delete(appKeyHex, { persist: false })
708 throw err
709 }
710 }
711
712 // Durable state now exists for this drive, so the reservation becomes a
713 // committed commitment. Ordered AFTER persistEntry for the same reason
714 // _reconcileSeedOptsOnRepin commits last: a crash between the persist and
715 // the commit leaves a durable entry that startup re-adopts via
716 // adoptRecovery(), whereas committing first would leave a commitment in
717 // the ledger with no durable entry backing it.
718 //
719 // owns() guards the commit because commit() on a token this authority no
720 // longer holds trips failClosed() and wedges the whole relay.
721 if (admissionReservation && typeof node.storageAdmission.owns === 'function' &&
722 node.storageAdmission.owns(admissionReservation)) {
723 node.storageAdmission.commit(admissionReservation)
724 admissionReservation = null
725 }
479726
480727 // 2026-05-23: register persistent download ranges on the drive's
481728 // cores so they actively pull missing blocks from any peer that
……@@ -510,11 +757,38 @@ export class AppLifecycle extends EventEmitter {
510757 this.emit('seeding', { appKey: appKeyHex, discoveryKey: b4a.toString(discoveryKey, 'hex') })
511758 return { discoveryKey: b4a.toString(discoveryKey, 'hex') }
512759 } catch (err) {
513 try { await drive.close() } catch (_) {}
760 // Drive first (it may not exist yet — the eviction check and the
761 // Hyperdrive constructor both throw with `drive` still null), then undo
762 // the admission reservation so a failed seed leaves the ledger exactly as
763 // it found it.
764 if (drive) { try { await drive.close() } catch (_) {} }
765 this._rollbackSeedAdmission(admissionReservation)
514766 throw err
515767 }
516768 }
517769
770 /**
771 * Undo a still-reserved drive admission from _seedAppInner's failure paths.
772 *
773 * rollback() — not release() — is reserve()'s counterpart. rollback restores
774 * the token's `previous` record, which on the reseed-a-placeholder path is
775 * the adoptRecovery() commitment made for this drive at startup. release()
776 * would delete that record outright, dropping a durable commitment from the
777 * ledger and understating the relay's storage debt by the drive's whole cap.
778 *
779 * owns() guards the call so a reservation that was already committed (or
780 * superseded by another holder) is left alone rather than clobbered.
781 */
782 _rollbackSeedAdmission (reservation) {
783 const node = this.node
784 if (!reservation || !node || !node.storageAdmission) return
785 const authority = node.storageAdmission
786 if (typeof authority.rollback !== 'function' || typeof authority.owns !== 'function') return
787 try {
788 if (authority.owns(reservation)) authority.rollback(reservation)
789 } catch (_) {}
790 }
791
518792 /**
519793 * Fire-and-forget wrapper around _eagerReplicate that participates in
520794 * the LifecycleScope cancellation contract (see lifecycle-scope.js +
……@@ -933,11 +1207,62 @@ export class AppLifecycle extends EventEmitter {
9331207
9341208 return { ok: true, changed: true, incrementalBytes, replicationStarted: true }
9351209 }
1210 /**
1211 * Read /manifest.json for indexing.
1212 *
1213 * With a storage proof, the read is taken from a checkout pinned to the
1214 * proven drive version and is strictly LOCAL (`wait: false`). Two reasons:
1215 *
1216 * - Honesty: the metadata we index must be the bytes the proof covers. A
1217 * live `drive.get()` can resolve against a newer version — or a forked
1218 * chain — so the registry would advertise a version nobody proved.
1219 * - Leak: the old read raced `drive.get()` against a 5s timer. Losing the
1220 * race rejected the caller but left the get outstanding, so a timed-out
1221 * manifest lookup kept an in-flight network request alive against a
1222 * drive the caller had already given up on.
1223 *
1224 * The checkout is always closed, including on the mismatch and error paths.
1225 * Without a proof this falls back to the legacy timed live read.
1226 */
1227 async _readPinnedManifest (drive, proof) {
1228 if (!proof || typeof drive.checkout !== 'function') {
1229 return Promise.race([
1230 drive.get('/manifest.json'),
1231 new Promise((_resolve, reject) => setTimeout(() => reject(new Error('manifest timeout')), 5000))
1232 ])
1233 }
1234
1235 let checkout = null
1236 try {
1237 checkout = drive.checkout(proof.driveVersion)
1238 if (!checkout) return null
1239 if (typeof checkout.ready === 'function') await checkout.ready()
1240
1241 // Fork/length stability: the checkout must be the exact metadata-core
1242 // state the proof covers. A fork swap between proving and reading would
1243 // otherwise authorize indexing bytes from a different chain.
1244 const snapshot = proof.metaCoreSnapshot
1245 const core = checkout.db && checkout.db.core
1246 if (snapshot && core) {
1247 if (Number(core.fork ?? 0) !== Number(snapshot.fork ?? 0) ||
1248 Number(core.length ?? -1) !== Number(snapshot.length ?? -2)) {
1249 return null
1250 }
1251 }
1252
1253 return await checkout.get('/manifest.json', { wait: false })
1254 } finally {
1255 if (checkout && typeof checkout.close === 'function') {
1256 try { await checkout.close() } catch (_) {}
1257 }
1258 }
1259 }
1260
9361261 /**
9371262 * Read manifest.json from a drive and deduplicate by appId.
9381263 * If an older version of the same app is already seeded, unseed it.
9391264 */
940 async _indexAppManifest (appKeyHex, drive) {
1265 async _indexAppManifest (appKeyHex, drive, proof = null) {
9411266 const node = this.node
9421267 // Blind drives: publisher's privacy contract says "do not inspect."
9431268 // We don't open /manifest.json, don't persist any manifest-derived
……@@ -953,10 +1278,7 @@ export class AppLifecycle extends EventEmitter {
9531278 if (existingForBlindCheck && existingForBlindCheck.blind === true) return
9541279
9551280 try {
956 const manifestBuf = await Promise.race([
957 drive.get('/manifest.json'),
958 new Promise((_resolve, reject) => setTimeout(() => reject(new Error('manifest timeout')), 5000))
959 ])
1281 const manifestBuf = await this._readPinnedManifest(drive, proof)
9601282 if (!manifestBuf) return
9611283
9621284 const manifest = JSON.parse(manifestBuf.toString())
……@@ -1049,6 +1371,10 @@ export class AppLifecycle extends EventEmitter {
10491371 async repairUnanchored (appKeyHex, opts = {}) {
10501372 const node = this.node
10511373 if (!node.appRegistry) return false
1374 // Repair pulls blocks and re-anchors — both storage-producing. Under a
1375 // read-only physical authority the relay may still SERVE what it holds,
1376 // but it must not grow. Stop before drive.update()/download().
1377 if (!this._durableWritesAvailable()) return false
10521378 const entry = node.appRegistry.get(appKeyHex)
10531379 if (!entry || !entry.drive) return false
10541380 if (entry.anchored === true) return true // already anchored, nothing to do
……@@ -1236,6 +1562,7 @@ export class AppLifecycle extends EventEmitter {
12361562 async runRepairPass (opts = {}) {
12371563 const node = this.node
12381564 if (!node.appRegistry) return { checked: 0, repaired: 0, stillUnanchored: 0 }
1565 if (!this._durableWritesAvailable()) return { checked: 0, repaired: 0, stillUnanchored: 0 }
12391566 const scope = node && node._scope
12401567 if (scope && scope.aborted) return { checked: 0, repaired: 0, stillUnanchored: 0 }
12411568
……@@ -1289,11 +1616,75 @@ export class AppLifecycle extends EventEmitter {
12891616 if (pending.length > 0) await Promise.allSettled(pending)
12901617 }
12911618
1619 /**
1620 * Ordered, retry-safe teardown of one auxiliary share-bundle resource.
1621 *
1622 * Order matters and is the inverse of acquisition: stop new replication
1623 * (listener) → stop discovery → stop the download tracker → close the
1624 * snapshot session → close the core → close a store we own. Closing the
1625 * core before its snapshot session, or the store before its cores, throws
1626 * SESSION_CLOSED / "Mutex has been destroyed" out of teardown.
1627 *
1628 * Each step records its own settled flag, so a step that rejects leaves
1629 * every DOWNSTREAM owner untouched and the resource parked in
1630 * _auxShareBundleResources for a retry — the same ownership discipline
1631 * unseedApp uses for drive retirement. The error propagates; callers that
1632 * must not fail (best-effort cleanup) catch it themselves.
1633 */
12921634 async _releaseAuxShareBundleResource (resource) {
12931635 if (!resource) return
1636 const node = this.node || {}
1637
1638 if (!resource.listenerSettled) {
1639 const swarm = node.swarm
1640 if (resource.onConnection && swarm && typeof swarm.removeListener === 'function') {
1641 swarm.removeListener('connection', resource.onConnection)
1642 }
1643 resource.listenerSettled = true
1644 }
1645
1646 if (!resource.discoverySettled) {
1647 if (resource.discovery && typeof resource.discovery.destroy === 'function') {
1648 await resource.discovery.destroy()
1649 } else if (resource.core && node.swarm && typeof node.swarm.leave === 'function') {
1650 await node.swarm.leave(resource.core.discoveryKey)
1651 }
1652 resource.discoverySettled = true
1653 }
1654
1655 if (!resource.trackerSettled) {
1656 if (resource.tracker && typeof resource.tracker.destroy === 'function') {
1657 resource.tracker.destroy()
1658 }
1659 resource.trackerSettled = true
1660 }
1661
1662 if (!resource.snapshotSettled) {
1663 if (resource.snapshotCore && typeof resource.snapshotCore.close === 'function') {
1664 await resource.snapshotCore.close()
1665 }
1666 resource.snapshotSettled = true
1667 }
1668
1669 if (!resource.coreSettled) {
1670 if (resource.core && typeof resource.core.close === 'function') {
1671 await resource.core.close()
1672 }
1673 resource.coreSettled = true
1674 }
1675
1676 if (!resource.storeSettled) {
1677 // Only close a store this read OWNS. When _readShareBundle falls back to
1678 // node.store the corestore belongs to the node — closing it here would
1679 // tear down every drive on the relay.
1680 if (resource.auxStore && resource.auxStore !== node.store &&
1681 typeof resource.auxStore.close === 'function') {
1682 await resource.auxStore.close()
1683 }
1684 resource.storeSettled = true
1685 }
1686
12941687 this._auxShareBundleResources.delete(resource)
1295 try { if (resource.tracker && typeof resource.tracker.destroy === 'function') resource.tracker.destroy() } catch (_) {}
1296 try { if (resource.snapshotCore && typeof resource.snapshotCore.close === 'function') await resource.snapshotCore.close() } catch (_) {}
12971688 }
12981689
12991690 async _readShareBundle (shareBundleKey, opts = {}) {
……@@ -1313,6 +1704,7 @@ export class AppLifecycle extends EventEmitter {
13131704 let auxStore = null
13141705 let discovery = null
13151706 let onConnection = null
1707 let ownedResource = null
13161708 try {
13171709 if (typeof opts.createAuxStore === 'function') {
13181710 auxStore = await opts.createAuxStore()
……@@ -1327,15 +1719,12 @@ export class AppLifecycle extends EventEmitter {
13271719 try { auxStore.replicate(conn) } catch (_) {}
13281720 }
13291721 if (typeof node.swarm.on === 'function') node.swarm.on('connection', onConnection)
1330 core = auxStore.get({ key: b4a.from(shareBundleKey, 'hex') })
1331 await core.ready()
1332 discovery = node.swarm.join(core.discoveryKey, { server: false, client: true })
1333 if (discovery && typeof discovery.flushed === 'function') {
1334 await Promise.race([
1335 discovery.flushed().catch(() => {}),
1336 new Promise(resolve => { const tmr = setTimeout(resolve, Math.min(timeoutMs, 5_000)); if (typeof tmr.unref === 'function') tmr.unref() })
1337 ])
1338 }
1722 // Exactly one core session + one topic join per read. This block was
1723 // duplicated verbatim by the da70b0f restore: the second acquisition
1724 // overwrote `core`/`discovery` with the first pair still open, so every
1725 // read leaked one core session and one PeerDiscovery join (neither is
1726 // reachable from the teardown resource, which only ever saw the second
1727 // pair) and paid the flush race twice.
13391728 core = auxStore.get({ key: b4a.from(shareBundleKey, 'hex') })
13401729 await core.ready()
13411730 discovery = node.swarm.join(core.discoveryKey, { server: false, client: true })
……@@ -1355,7 +1744,7 @@ export class AppLifecycle extends EventEmitter {
13551744 // flight.
13561745 let resolveSettled
13571746 const operationSettled = new Promise(resolve => { resolveSettled = resolve })
1358 const ownedResource = { appKey, core, snapshotCore: null, tracker: null, auxStore, discovery, onConnection, operationSettled }
1747 ownedResource = { appKey, core, snapshotCore: null, tracker: null, auxStore, discovery, onConnection, operationSettled }
13591748 this._auxShareBundleResources.add(ownedResource)
13601749 try {
13611750 if (typeof core.update === 'function') {
……@@ -1405,20 +1794,17 @@ export class AppLifecycle extends EventEmitter {
14051794 } catch (_) {
14061795 return null
14071796 } finally {
1408 if (onConnection && typeof node.swarm.removeListener === 'function') {
1409 try { node.swarm.removeListener('connection', onConnection) } catch (_) {}
1410 }
1411 if (discovery && typeof discovery.destroy === 'function') {
1412 try { await discovery.destroy() } catch (_) {}
1413 } else if (core) {
1414 try { await node.swarm.leave(core.discoveryKey) } catch (_) {}
1415 }
1416 if (core) {
1417 try { await core.close() } catch (_) {}
1418 }
1419 if (auxStore && auxStore !== node.store && typeof auxStore.close === 'function') {
1420 try { await auxStore.close() } catch (_) {}
1421 }
1797 // Single ordered teardown. Previously this block open-coded the release
1798 // and did NOT close the snapshot session or destroy the download
1799 // tracker — _releaseAuxShareBundleResource was the only code that did,
1800 // and nothing called it. Every snapshot-path share-bundle read therefore
1801 // leaked a core session and a download tracker.
1802 //
1803 // The synthetic fallback covers the early `return null` paths that bail
1804 // before the tracked resource exists but after cores/discovery are open.
1805 const resource = ownedResource ||
1806 { appKey, core, snapshotCore: null, tracker: null, auxStore, discovery, onConnection }
1807 try { await this._releaseAuxShareBundleResource(resource) } catch (_) {}
14221808 }
14231809 }
14241810
……@@ -1506,6 +1892,15 @@ export class AppLifecycle extends EventEmitter {
15061892 if (!owner) {
15071893 const entry = node.appRegistry.get(appKeyHex)
15081894 if (!entry) return
1895 // A forget retirement ENDS in a durable delete, and that delete hits
1896 // memory first. If the durable write then fails we restore the entry —
1897 // but the restore is itself a registry write, so under an unavailable
1898 // physical authority it fails too and the drive vanishes from the
1899 // registry entirely. Refuse before the first mutation instead of
1900 // discovering it half-way through teardown.
1901 if (opts.forget !== false && typeof node.appRegistry.assertDurableWritesAvailable === 'function') {
1902 node.appRegistry.assertDurableWritesAvailable()
1903 }
15091904 entry.retiring = true
15101905 owner = {
15111906 appKey: appKeyHex,
……@@ -1513,27 +1908,87 @@ export class AppLifecycle extends EventEmitter {
15131908 forget: opts.forget !== false,
15141909 bridgeRegistered: !!node.distributedDriveBridge,
15151910 rangesSettled: false,
1516 driveSettled: false
1911 snapshotCoresSettled: false,
1912 discoverySettled: false,
1913 driveSettled: false,
1914 commitmentReleased: false,
1915 completed: false,
1916 inflight: null
15171917 }
15181918 this._retiringDrives.set(appKeyHex, owner)
15191919 } else {
15201920 if (opts.forget !== undefined) owner.forget = opts.forget !== false
15211921 }
15221922
1523 // Phase 1: Settle live resources (download ranges → bridge → drive).
1524 // Ordered so the durable delete (Phase 2) cannot race with in-flight
1525 // replication or bridge lookups. Only runs once — on retry these are
1526 // already null.
1923 // Coalesce concurrent retirements of the same drive onto ONE owner. A
1924 // second caller (e.g. an eviction upgrading a shutdown to a forget) must
1925 // not re-close resources the first caller already has in flight — it
1926 // chains behind it instead. Its intent is applied above, so a forget
1927 // arriving mid-shutdown is still honoured by the in-flight pass.
1928 const previous = owner.inflight || Promise.resolve()
1929 const run = previous.catch(() => {})
1930 .then(() => this._withDriveLease(appKeyHex, () => this._retireDrive(owner), { allowWhenStopping: true }))
1931 owner.inflight = run.catch(() => {})
1932 return run
1933 }
1934
1935 /**
1936 * Idempotent drive retirement. Every step records a settled flag on the
1937 * owner, so a rejection parks the owner in _retiringDrives with all
1938 * DOWNSTREAM owners intact and a retry resumes exactly where it stopped.
1939 */
1940 async _retireDrive (owner) {
1941 if (owner.completed) return
1942 const node = this.node
1943 const appKeyHex = owner.appKey
1944
1945 // Phase 1: Settle live resources, inverse of acquisition — stop pulling
1946 // bytes (ranges → proof snapshots), stop being findable (discovery),
1947 // stop bridging, then close the drive. Ordered so the durable delete
1948 // (Phase 2) cannot race with in-flight replication or bridge lookups.
1949 // A step that throws leaves everything after it untouched for the retry.
15271950 if (!owner.rangesSettled) {
1528 if (Array.isArray(owner.entry.downloadRanges)) {
1529 for (const dl of owner.entry.downloadRanges) {
1530 try { if (dl && typeof dl.destroy === 'function') dl.destroy() } catch (_) {}
1951 const ranges = owner.entry.downloadRanges
1952 if (Array.isArray(ranges)) {
1953 while (ranges.length > 0) {
1954 const dl = ranges[0]
1955 if (dl && typeof dl.destroy === 'function') dl.destroy()
1956 ranges.shift()
15311957 }
15321958 owner.entry.downloadRanges = null
15331959 }
15341960 owner.rangesSettled = true
15351961 }
15361962
1963 if (!owner.snapshotCoresSettled) {
1964 const cores = owner.entry.downloadSnapshotCores
1965 if (Array.isArray(cores)) {
1966 while (cores.length > 0) {
1967 const core = cores[0]
1968 if (core && typeof core.close === 'function') await core.close()
1969 cores.shift()
1970 }
1971 owner.entry.downloadSnapshotCores = null
1972 }
1973 owner.snapshotCoresSettled = true
1974 }
1975
1976 // Destroy the exact PeerDiscovery handles this drive owns, rather than
1977 // leaving the topic by key: swarm.leave(discoveryKey) cannot distinguish
1978 // this drive's join from another subsystem's join on the same topic. A
1979 // handle that fails to destroy is retained by identity for the retry.
1980 if (!owner.discoverySettled) {
1981 const handles = owner.entry.discoveryHandles
1982 if (handles && typeof handles.delete === 'function') {
1983 for (const handle of [...handles]) {
1984 if (handle && typeof handle.destroy === 'function') await handle.destroy()
1985 handles.delete(handle)
1986 }
1987 owner.entry.discoveryHandles = null
1988 }
1989 owner.discoverySettled = true
1990 }
1991
15371992 if (owner.bridgeRegistered && node.distributedDriveBridge) {
15381993 node.distributedDriveBridge.unregisterDrive(appKeyHex)
15391994 owner.bridgeRegistered = false
……@@ -1574,10 +2029,13 @@ export class AppLifecycle extends EventEmitter {
15742029 // Phase 3: Complete — the durable delete succeeded, so leave the swarm
15752030 // and release the storage admission commitment.
15762031 try { if (node.swarm && typeof node.swarm.leave === 'function') await node.swarm.leave(owner.entry.discoveryKey) } catch (_) {}
1577 if (owner.forget && node.storageAdmission && typeof node.storageAdmission.release === 'function') {
2032 if (owner.forget && !owner.commitmentReleased &&
2033 node.storageAdmission && typeof node.storageAdmission.release === 'function') {
15782034 try { node.storageAdmission.release(`drive:${appKeyHex}`) } catch (_) {}
2035 owner.commitmentReleased = true
15792036 }
15802037
2038 owner.completed = true
15812039 owner.entry.retiring = false
15822040 this._retiringDrives.delete(appKeyHex)
15832041 this._safeEmit('unseeded', { appKey: appKeyHex })
packages/core/core/relay-node/cancellable-drive-update.js +90 -3
……@@ -240,11 +240,98 @@ export async function getDriveSize (drive, opts = {}) {
240240 // Metadata core size — usually already known after the initial drive.update,
241241 // but we re-update just in case the caller hasn't done one yet.
242242 await updateCore(metaCore)
243 const metaBytes = (metaCore && metaCore.byteLength) || 0
244243
245244 // Blob core size — separate update required; drive.update doesn't touch it.
246245 await updateCore(blobCore)
247 const blobBytes = (blobCore && blobCore.byteLength) || 0
248246
249 return { totalBytes: metaBytes + blobBytes, metaBytes, blobBytes }
247 if (!opts.requireAuthoritative) {
248 const metaBytes = (metaCore && metaCore.byteLength) || 0
249 const blobBytes = (blobCore && blobCore.byteLength) || 0
250 return { totalBytes: metaBytes + blobBytes, metaBytes, blobBytes }
251 }
252
253 // ── Authoritative mode ────────────────────────────────────────────────
254 // A storage proof must describe ONE state of the drive. `length` and
255 // `byteLength` are two separate getters, so an append landing between them
256 // yields a tuple that never existed: the old byteLength paired with the new
257 // length (or vice versa). Sizing a cap or signing a proof off that tuple
258 // under-counts the drive. Read length → byteLength → length again and retry
259 // until the bracketing reads agree.
260 const metaState = readStableCoreState(metaCore)
261 const blobState = readStableCoreState(blobCore)
262
263 const result = {
264 metaBytes: metaState.byteLength,
265 blobBytes: blobState.byteLength,
266 totalBytes: metaState.byteLength + blobState.byteLength,
267 metaLength: metaState.length,
268 blobLength: blobState.length,
269 metaFork: metaState.fork,
270 blobFork: blobState.fork
271 }
272
273 if (!opts.pinSnapshots) return result
274
275 // Pinned proof: capture a snapshot session per core so the caller can read
276 // the exact bytes the proof covers. drive.version must not move across the
277 // capture — if it does, the two snapshots belong to different drive states
278 // and the proof would attest to a version that never existed.
279 const versionBefore = drive ? drive.version : undefined
280 const snapshots = []
281 try {
282 for (const core of [metaCore, blobCore]) {
283 if (core && typeof core.snapshot === 'function') {
284 const snap = core.snapshot()
285 snapshots.push(snap)
286 if (snap && typeof snap.ready === 'function') await snap.ready()
287 } else {
288 snapshots.push(null)
289 }
290 }
291 const versionAfter = drive ? drive.version : undefined
292 if (versionBefore !== versionAfter) {
293 const err = new Error(
294 `DRIVE_VERSION_CHANGED_DURING_PROOF: ${versionBefore} -> ${versionAfter}`
295 )
296 err.code = 'DRIVE_VERSION_CHANGED_DURING_PROOF'
297 throw err
298 }
299 result.driveVersion = versionAfter
300 result.metaCoreSnapshot = snapshots[0]
301 result.blobCoreSnapshot = snapshots[1]
302 return result
303 } catch (err) {
304 // Stale snapshots are ours to release — the caller never saw them.
305 for (const snap of snapshots) {
306 if (snap && typeof snap.close === 'function') {
307 try { await snap.close() } catch (_) {}
308 }
309 }
310 throw err
311 }
312}
313
314const STABLE_READ_ATTEMPTS = 8
315
316/**
317 * Read a core's (length, byteLength, fork) as one consistent tuple.
318 *
319 * Bracketing: read length, then byteLength, then length again. Matching
320 * bracket reads mean no append landed in between, so the byteLength belongs
321 * to that length. Bounded so a core appending in a tight loop fails loudly
322 * instead of spinning.
323 */
324function readStableCoreState (core) {
325 if (!core) return { length: 0, byteLength: 0, fork: 0 }
326 for (let attempt = 0; attempt < STABLE_READ_ATTEMPTS; attempt++) {
327 const lengthBefore = core.length || 0
328 const byteLength = core.byteLength || 0
329 const lengthAfter = core.length || 0
330 if (lengthBefore === lengthAfter) {
331 return { length: lengthAfter, byteLength, fork: core.fork || 0 }
332 }
333 }
334 const err = new Error('CORE_STATE_UNSTABLE: core kept appending across size reads')
335 err.code = 'CORE_STATE_UNSTABLE'
336 throw err
250337}
packages/core/core/relay-node/index.js +8 -0
……@@ -779,6 +779,14 @@ export class RelayNode extends EventEmitter {
779779 return this.appLifecycle.seededApps
780780 }
781781
782 // Public read-only view of the opt-in forward transport, so the capability
783 // doc can advertise `hiverelay-forward` the same way it advertises tor /
784 // dht-relay-ws / holesail. The backing field stays `_forwardRelay` because
785 // the shutdown paths destroy protocol handlers by that key name.
786 get forwardRelay () {
787 return this._forwardRelay || null
788 }
789
782790 /**
783791 * Register a fire-and-forget promise with the active LifecycleScope so
784792 * stop() drains it before tearing down state. Used by all the
packages/core/gateway/hyper-gateway.js +10 -2
……@@ -458,7 +458,11 @@ export class HyperGateway extends EventEmitter {
458458 return await this.node.storageAdmission.runKeyMutation(`drive:${keyHex}`, async () => {
459459 req[TRACKED_GATEWAY_STORAGE_LEASE] = true
460460 try {
461 return await this.handle(req, res)
461 // Forward `context`. Dropping it here silently disabled the
462 // per-window egress byte budget (reserveResponseBytes) and the
463 // X-Hive-App-Key / X-Hive-Byte-Mode headers for every exact
464 // app-origin response, because the re-entrant call saw context=null.
465 return await this.handle(req, res, context)
462466 } finally {
463467 delete req[TRACKED_GATEWAY_STORAGE_LEASE]
464468 }
……@@ -551,7 +555,11 @@ export class HyperGateway extends EventEmitter {
551555 // caller-supplied egress budget BEFORE headers so a body-bearing
552556 // response cannot exceed the per-window byte cap. HEAD is body-free
553557 // and does not consume the budget.
554 if (!isHead && typeof context.reserveResponseBytes === 'function' &&
558 // `context` is null on the key-path route (gateway-server serves
559 // /v1/hyper/:key with no exact-app context), so this must null-guard —
560 // without it every non-HEAD file GET on the public read plane threw
561 // "Cannot read properties of null" and returned 502.
562 if (!isHead && context && typeof context.reserveResponseBytes === 'function' &&
555563 context.reserveResponseBytes(byteLength) !== true) {
556564 const retryAfter = Math.ceil((context.egressRetryAfterSeconds || 60))
557565 // A rate-limited response is gateway-generated (not an exact drive
test/unit/app-lifecycle-persistence.test.js +86 -0
……@@ -412,3 +412,89 @@ test('AppLifecycle: queued seed admission times out or aborts without later exec
412412 t.is(runs, 1, `${mode}: cancelled queued closure never runs later`)
413413 }
414414})
415
416async function admissionNode (t) {
417 const storage = tmpStorage()
418 await mkdir(storage, { recursive: true })
419 const node = new RelayNode({
420 storage,
421 enableAPI: false,
422 enableNetworkDiscovery: false,
423 enableHolesail: false,
424 physicalEnforcer: testPhysicalEnforcer(storage)
425 })
426 t.teardown(async () => {
427 try { await node.stop() } catch (_) {}
428 try { await node.store.close() } catch (_) {}
429 await rm(storage, { recursive: true, force: true })
430 })
431 await node.start()
432 return node
433}
434
435test('AppLifecycle: successful seed commits its storage admission reservation', async (t) => {
436 const node = await admissionNode(t)
437 const appKey = randomBytes(32).toString('hex')
438 const cap = 1024 * 1024
439
440 await node.seedApp(appKey, { maxStorage: cap })
441
442 const record = node.storageAdmission.get(`drive:${appKey}`)
443 t.is(record.state, 'committed', 'seed commits the reservation instead of abandoning it in `reserved`')
444 t.is(record.boundBytes, cap, 'the committed bound is the requested cap')
445 t.ok(node.storageAdmission.canAcknowledge(`drive:${appKey}`), 'the drive can be acknowledged from durable state')
446
447 // An abandoned `reserved` record makes canAcknowledge() false, which turns
448 // every re-pin into STORAGE_RECONCILIATION_REQUIRED.
449 const repin = await node.seedApp(appKey, { maxStorage: cap })
450 t.ok(repin.alreadySeeded, 're-pin ACKs from the committed ledger record')
451
452 // ...and makes reserve() fail with `storage-reservation-in-progress`, which
453 // surfaces as STORAGE_CAP_REPIN_BLOCKED on any cap raise.
454 await node.seedApp(appKey, { maxStorage: cap * 2 })
455 t.is(node.storageAdmission.get(`drive:${appKey}`).boundBytes, cap * 2, 'a later cap raise can still reserve this key')
456 t.absent(node.storageAdmission.fatalReason, 'no invariant violation was tripped')
457})
458
459test('AppLifecycle: failed seed rolls its reservation back to the adopted commitment', async (t) => {
460 const node = await admissionNode(t)
461 const appKey = randomBytes(32).toString('hex')
462 const cap = 1024 * 1024
463
464 node.appRegistry.set(appKey, { discoveryKey: null, maxStorage: cap, type: 'app' }, { persist: false })
465 const adopted = node.storageAdmission.adoptRecovery(`drive:${appKey}`, cap, { kind: 'drive' })
466
467 const persistEntry = node.appRegistry.persistEntry.bind(node.appRegistry)
468 node.appRegistry.persistEntry = async () => { throw new Error('disk full') }
469
470 await t.exception(node.seedApp(appKey, { maxStorage: cap }), /disk full/)
471
472 // rollback() restores the startup adoptRecovery() commitment. release() would
473 // have deleted it, silently dropping this drive's durable debt from the ledger.
474 t.alike(node.storageAdmission.get(`drive:${appKey}`), adopted, 'the failed seed restores the adopted commitment exactly')
475 t.ok(node.storageAdmission.canAcknowledge(`drive:${appKey}`), 'the recovered drive is still acknowledgeable')
476
477 node.appRegistry.persistEntry = persistEntry
478 const retry = await node.seedApp(appKey, { maxStorage: cap })
479 t.ok(retry.discoveryKey, 'a failed seed does not wedge the key in `reserved` against every retry')
480 t.is(node.storageAdmission.get(`drive:${appKey}`).state, 'committed')
481 t.absent(node.storageAdmission.fatalReason)
482})
483
484test('AppLifecycle: seed failure before the drive exists still rolls the reservation back', async (t) => {
485 const node = await admissionNode(t)
486 const appKey = randomBytes(32).toString('hex')
487
488 // Throws from the Hyperdrive construction — earlier than the drive-close
489 // catch that used to be _seedAppInner's only failure handler.
490 const session = node.store.session.bind(node.store)
491 node.store.session = () => { throw new Error('store wedged') }
492
493 await t.exception(node.seedApp(appKey, { maxStorage: 1024 * 1024 }), /store wedged/)
494 t.absent(node.storageAdmission.get(`drive:${appKey}`), 'a reservation with no prior record is removed, not left reserved')
495
496 node.store.session = session
497 const retry = await node.seedApp(appKey, { maxStorage: 1024 * 1024 })
498 t.ok(retry.discoveryKey, 'the key is reservable again after the pre-drive failure')
499 t.absent(node.storageAdmission.fatalReason)
500})
test/unit/capability-doc.test.js +13 -0
……@@ -75,6 +75,19 @@ test('detects transports from runtime state', async (t) => {
7575 t.ok(doc.supported_transports.includes('holesail'))
7676})
7777
78test('advertises hiverelay-forward only when the opt-in forward relay is enabled', async (t) => {
79 const base = { config: { discovery: { dht: true } } }
80
81 const off = buildCapabilityDoc({ relay: { ...base } })
82 t.absent(off.supported_transports.includes('hiverelay-forward'), 'not advertised when forward relay is absent')
83
84 const disabled = buildCapabilityDoc({ relay: { ...base, forwardRelay: { enabled: false } } })
85 t.absent(disabled.supported_transports.includes('hiverelay-forward'), 'not advertised when constructed but disabled')
86
87 const on = buildCapabilityDoc({ relay: { ...base, forwardRelay: { enabled: true } } })
88 t.ok(on.supported_transports.includes('hiverelay-forward'), 'advertised when the operator opted in')
89})
90
7891test('features list is sorted and reflects wired subsystems', async (t) => {
7992 const relay = {
8093 config: {},
test/unit/custody-share-bundle.test.js +55 -0
……@@ -553,3 +553,58 @@ test('_readShareBundle: active auxiliary fetch drains before concurrent unseed r
553553 t.ok(events.indexOf('range-destroy') < events.indexOf('authority-release'))
554554 t.ok(events.indexOf('drive-close') < events.indexOf('authority-release'))
555555})
556
557test('_readShareBundle: acquires exactly one core session and one topic join per read', async (t) => {
558 // The da70b0f restore duplicated the acquire-core + join-topic block verbatim.
559 // Both copies ran, the second overwrote `core`/`discovery`, and only that
560 // second pair reached teardown — so each read stranded one core session and
561 // one PeerDiscovery join. The harnesses above cannot see it: they hand back
562 // one shared core object and discard the join handle. Count both here.
563 let sessions = 0
564 let joins = 0
565 let closes = 0
566 let destroys = 0
567
568 const newCore = () => ({
569 key: b4a.alloc(32, 9),
570 discoveryKey: b4a.alloc(32, 10),
571 fork: 0,
572 length: 1,
573 byteLength: 16,
574 async ready () {},
575 async update () { return true },
576 async get () { return b4a.from('{"ok":true}') },
577 async close () { closes++ }
578 })
579
580 const node = {
581 swarm: {
582 join () { joins++; return { async flushed () {}, async destroy () { destroys++ } } },
583 async leave () {},
584 on () {},
585 removeListener () {}
586 },
587 storageAdmission: {
588 mutationAdmission: () => ({ allowed: true }),
589 canAcknowledge: () => true
590 }
591 }
592
593 const lifecycle = new AppLifecycle(node)
594 const result = await lifecycle._readShareBundle('d'.repeat(64), {
595 appKey: 'c'.repeat(64),
596 timeoutMs: 100,
597 createAuxStore: async () => ({
598 async ready () {},
599 get () { sessions++; return newCore() },
600 replicate () {},
601 async close () {}
602 })
603 })
604
605 t.alike(result, { ok: true }, 'the bundle still parses')
606 t.is(sessions, 1, 'one core session per read')
607 t.is(joins, 1, 'one topic join per read')
608 t.is(closes, 1, 'the single session is closed — no orphan left behind')
609 t.is(destroys, 1, 'the single discovery is destroyed — no orphan join left behind')
610})