@ 261107.5
current
'use strict'

// The connector's TLS session cache holds sessions through WeakRefs. When the
// cache is full and an entry's session has been collected, that dead entry
// should make room for the new session, without discarding the new session
// or evicting a live one.
//
// tls.connect is stubbed so the test controls the session objects, and so
// when they can be collected.

const { test, afterEach } = require('node:test')
const assert = require('node:assert')
const tls = require('node:tls')
const v8 = require('node:v8')
const vm = require('node:vm')
const { EventEmitter } = require('node:events')
const { setImmediate: tick, setTimeout: sleep } = require('node:timers/promises')
const { buildConnector } = require('..')

v8.setFlagsFromString('--expose-gc')
const gc = vm.runInNewContext('gc')

const originalConnect = tls.connect
afterEach(() => {
  tls.connect = originalConnect
})

function stubConnector (opts) {
  const offered = []
  let socket
  tls.connect = (options) => {
    offered.push(options.session)
    socket = new EventEmitter()
    socket.setKeepAlive = () => socket
    socket.setNoDelay = () => socket
    socket.destroy = () => socket
    return socket
  }

  const connector = buildConnector({ timeout: 0, ...opts })
  return {
    // Opens a connection and returns the session offered for resumption.
    connect (hostname) {
      connector({ hostname, host: hostname, protocol: 'https:', port: 443 }, () => {})
      return offered.at(-1)
    },
    // Emits a new session on the most recent connection.
    session (session) {
      socket.emit('session', session)
    }
  }
}

// Returns once `session` has been collected, keeping the cache entry.
// Finalization callbacks run in a later task than gc(), so the caller can
// act on the dead entry before the FinalizationRegistry sees it.
async function collect (makeSession) {
  let ref
  ;(() => { ref = new WeakRef(makeSession()) })()
  await tick()
  gc()
  assert.strictEqual(ref.deref(), undefined, 'session was collected')
}

test('a full cache replaces a collected entry with the new session', async () => {
  const cache = stubConnector({ maxCachedSessions: 2 })

  const sessionA = { name: 'A' }
  cache.connect('a.test')
  cache.session(sessionA)

  await collect(() => {
    const sessionB = { name: 'B' }
    cache.connect('b.test')
    cache.session(sessionB)
    return sessionB
  })

  const sessionC = { name: 'C' }
  cache.connect('c.test')
  cache.session(sessionC)

  assert.strictEqual(cache.connect('c.test'), sessionC, 'new session is cached')
  assert.strictEqual(cache.connect('a.test'), sessionA, 'live session is kept')
})

test('a collected entry left behind while the cache had room is replaced once it fills', async () => {
  const cache = stubConnector({ maxCachedSessions: 2 })

  await collect(() => {
    const sessionA = { name: 'A' }
    cache.connect('a.test')
    cache.session(sessionA)
    return sessionA
  })
  // Let the FinalizationRegistry run. The cache is below its limit, so it
  // leaves the dead entry in place.
  await sleep(20)

  const sessionB = { name: 'B' }
  cache.connect('b.test')
  cache.session(sessionB)

  const sessionC = { name: 'C' }
  cache.connect('c.test')
  cache.session(sessionC)

  assert.strictEqual(cache.connect('c.test'), sessionC, 'new session is cached')
  assert.strictEqual(cache.connect('b.test'), sessionB, 'live session is kept')
})

test('a full cache with no collected entries evicts the oldest session', async () => {
  const cache = stubConnector({ maxCachedSessions: 2 })

  const sessions = { a: { name: 'A' }, b: { name: 'B' }, c: { name: 'C' } }
  for (const key of ['a', 'b', 'c']) {
    cache.connect(`${key}.test`)
    cache.session(sessions[key])
  }

  assert.strictEqual(cache.connect('a.test'), null, 'oldest session evicted')
  assert.strictEqual(cache.connect('b.test'), sessions.b)
  assert.strictEqual(cache.connect('c.test'), sessions.c)
})