OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / test / unit / lifecycle-scope.test.js

234 lines · javascript ref:
/**
 * LifecycleScope unit tests — verify the cancellation contract primitive
 * the v0.8.13 Reliability v2 work uses to drain fire-and-forget loops at
 * stop() time. See CANCELLATION-CONTRACT.md.
 *
 * No real swarm / corestore needed — these tests exercise the primitive
 * in isolation.
 */

import test from 'brittle'
import { LifecycleScope, isAbortError } from 'p2p-hiverelay/core/relay-node/lifecycle-scope.js'

test('LifecycleScope: signal starts non-aborted, aborts on drain', async (t) => {
  const scope = new LifecycleScope()
  t.is(scope.aborted, false, 'fresh scope is not aborted')
  t.is(scope.signal.aborted, false, 'fresh signal is not aborted')
  await scope.drain()
  t.is(scope.aborted, true, 'aborted after drain')
  t.is(scope.signal.aborted, true, 'signal aborted after drain')
})

test('LifecycleScope: tracked() awaits pending promises before drain returns', async (t) => {
  const scope = new LifecycleScope()
  let resolved = false
  const p = new Promise((resolve) => {
    setTimeout(() => {
      resolved = true
      resolve('done')
    }, 50)
  })
  scope.tracked(p)

  const drainStart = Date.now()
  await scope.drain()
  const drainMs = Date.now() - drainStart

  t.is(resolved, true, 'tracked promise resolved before drain returned')
  t.ok(drainMs >= 40, 'drain blocked until the tracked promise settled (' + drainMs + 'ms)')
})

test('LifecycleScope: race() rejects with AbortError when signal fires first', async (t) => {
  const scope = new LifecycleScope()
  const slow = new Promise((resolve) => setTimeout(() => resolve('too late'), 1000))
  const racePromise = scope.race(slow)

  setTimeout(() => scope.drain(), 10) // fire abort early

  try {
    await racePromise
    t.fail('race should have rejected')
  } catch (err) {
    t.ok(isAbortError(err), 'race rejected with AbortError (name=' + err.name + ', code=' + err.code + ')')
  }
})

test('LifecycleScope: race() resolves normally when inner promise wins', async (t) => {
  const scope = new LifecycleScope()
  const fast = new Promise((resolve) => setTimeout(() => resolve('ok'), 5))
  const result = await scope.race(fast)
  t.is(result, 'ok')
  await scope.drain()
})

test('LifecycleScope: race() rejects immediately if scope already aborted', async (t) => {
  const scope = new LifecycleScope()
  await scope.drain()
  let innerEverEvaluated = false
  const inner = new Promise(() => { innerEverEvaluated = true })
  try {
    await scope.race(inner)
    t.fail('race should have rejected synchronously')
  } catch (err) {
    t.ok(isAbortError(err), 'race rejected with AbortError')
    t.is(innerEverEvaluated, true, 'inner promise constructor still ran (expected — Promise body is synchronous), but resolver never fired')
  }
})

test('LifecycleScope: sleep() rejects with AbortError when signal fires mid-sleep', async (t) => {
  const scope = new LifecycleScope()
  const sleepPromise = scope.sleep(1000)
  setTimeout(() => scope.drain(), 10)

  const start = Date.now()
  try {
    await sleepPromise
    t.fail('sleep should have rejected')
  } catch (err) {
    const ms = Date.now() - start
    t.ok(isAbortError(err), 'sleep rejected with AbortError')
    t.ok(ms < 500, 'sleep bailed promptly (' + ms + 'ms < 500ms)')
  }
})

test('LifecycleScope: sleep() resolves after the requested delay if not aborted', async (t) => {
  const scope = new LifecycleScope()
  const start = Date.now()
  await scope.sleep(40)
  const ms = Date.now() - start
  t.ok(ms >= 30, 'sleep waited (' + ms + 'ms)')
  await scope.drain()
})

test('LifecycleScope: drain() is idempotent', async (t) => {
  const scope = new LifecycleScope()
  scope.tracked(Promise.resolve('once'))
  await scope.drain()
  // Second call should be a no-op, not hang.
  await scope.drain()
  t.is(scope.aborted, true)
})

test('LifecycleScope: concurrent drains share the pending settlement barrier', async (t) => {
  const scope = new LifecycleScope()
  let release = null
  const gate = new Promise(resolve => { release = resolve })
  scope.tracked(gate)

  const first = scope.drain()
  const second = scope.drain()
  t.is(first, second, 'both callers receive the exact drain promise')

  let secondSettled = false
  second.then(() => { secondSettled = true })
  await new Promise(resolve => setTimeout(resolve, 0))
  t.is(secondSettled, false, 'second caller remains blocked on the first snapshot')

  release()
  await Promise.all([first, second])
  t.is(secondSettled, true)
})

test('LifecycleScope: tracked() after drain() is a no-op (does not block second drain)', async (t) => {
  const scope = new LifecycleScope()
  await scope.drain()
  const p = new Promise(() => {}) // never resolves
  scope.tracked(p) // should not be added to a now-closed inflight set
  // If tracked() incorrectly added p to inflight, this second drain would hang forever.
  const start = Date.now()
  await scope.drain()
  const ms = Date.now() - start
  t.ok(ms < 100, 'second drain returned quickly (' + ms + 'ms) — post-drain tracked() was a no-op')
})

test('LifecycleScope: drain() waits for ALL tracked promises (allSettled, not first)', async (t) => {
  const scope = new LifecycleScope()
  const slowOk = new Promise((resolve) => setTimeout(() => resolve('slow ok'), 80))
  const fastReject = new Promise((_resolve, reject) => setTimeout(() => reject(new Error('fast fail')), 10))
  scope.tracked(slowOk)
  scope.tracked(fastReject.catch(() => {})) // standard fire-and-forget pattern: swallow with .catch

  const start = Date.now()
  await scope.drain()
  const ms = Date.now() - start
  t.ok(ms >= 70, 'drain waited for the slow promise too (' + ms + 'ms)')
})

test('LifecycleScope: throwIfAborted throws AbortError when aborted', async (t) => {
  const scope = new LifecycleScope()
  scope.throwIfAborted() // pre-abort: no throw
  await scope.drain()
  try {
    scope.throwIfAborted()
    t.fail('throwIfAborted should have thrown')
  } catch (err) {
    t.ok(isAbortError(err))
  }
})

test('LifecycleScope: realistic retry loop scenario — sleep + race wrapping a long await', async (t) => {
  const scope = new LifecycleScope()
  let iterations = 0
  let bailedCleanly = false

  // Simulate _eagerReplicate's loop shape: retry up to 6 times, each
  // attempt wraps a "long await" in scope.race() and uses scope.sleep()
  // between attempts. The "long await" is a 200ms timer; abort fires
  // at 50ms, so iteration 0 should bail mid-await.
  const loop = (async () => {
    for (let attempt = 0; attempt < 6; attempt++) {
      if (scope.aborted) return
      iterations++
      try {
        const slowOp = new Promise((resolve) => setTimeout(resolve, 200))
        await scope.race(slowOp)
      } catch (err) {
        if (isAbortError(err)) { bailedCleanly = true; return }
        throw err
      }
      if (attempt < 5) {
        try {
          await scope.sleep(5000)
        } catch (err) {
          if (isAbortError(err)) { bailedCleanly = true; return }
          throw err
        }
      }
    }
  })()
  scope.tracked(loop)

  setTimeout(() => scope.drain(), 50)
  const start = Date.now()
  await loop
  const ms = Date.now() - start

  t.ok(bailedCleanly, 'loop exited via AbortError, not by completing all retries')
  t.is(iterations, 1, 'loop bailed during the first iteration')
  t.ok(ms < 500, 'loop bailed promptly (' + ms + 'ms) — no waiting through the 200ms slowOp or 5s sleep')
})

test('isAbortError: recognizes scope-thrown AbortErrors', async (t) => {
  const scope = new LifecycleScope()
  await scope.drain()
  try {
    await scope.race(Promise.resolve())
    t.fail('should have rejected')
  } catch (err) {
    t.ok(isAbortError(err))
  }

  // Also recognizes the duck-typed form (no scope dependency).
  const e = new Error('abort')
  e.name = 'AbortError'
  t.ok(isAbortError(e))

  const e2 = new Error('abort')
  e2.code = 'ABORT_ERR'
  t.ok(isAbortError(e2))

  t.is(isAbortError(new Error('something else')), false)
  t.is(isAbortError(null), false)
  t.is(isAbortError(undefined), false)
})