OverviewFilesCommitsIssuesDocsReleasesPeers

keet-daemon / test / chat-after-idle.test.js

209 lines · javascript ref:
// Repro: "Chat error: UNEXPECTED_ERROR: Autobase is closing" — hyper-fleeky-dev
//
// Hypothesis: keet-core 4.x's idle-room GC (closes idle rooms after ~43s,
// per keet-core-internals skill) tears down a chat room's autobase while
// the daemon has no `activateRoom()` keep-alive. The next send hits the
// closing autobase and throws.
//
// Steps:
//   1. Spawn daemon on a fresh tmpdir
//   2. vault.setup → identity-0 ready
//   3. room.create → a single-writer local room
//   4. send a message — should succeed (autobase fresh)
//   5. Sleep 60s (past the ~43s _gcIdleTicks×_gcInterval threshold)
//   6. send another message — observe whether this throws
//        "Autobase is closing"
//
// EXIT 0 = bug reproduced (second send throws Autobase is closing).
//          That confirms the GC race is the cause.
// EXIT 1 = bug NOT reproduced (second send succeeded, or threw something
//          else). Means the hypothesis is wrong and we need a different
//          angle.
// EXIT 2 = test setup failed (bug in the test itself).
//
// Run:
//   cd hyper-fleeky/packages/keet-daemon && bare test/chat-after-idle.test.js
//
// Touches NO user storage. Cleanup unconditional.

import fs from 'bare-fs'
import path from 'bare-path'
import os from 'bare-os'
import Pipe from 'bare-pipe'
import FramedStream from 'framed-stream'
import { startDaemon } from '../index.js'

const IDLE_WAIT_MS = 60_000 // > keet-core's ~43s idle-GC threshold

function rmrf (dir) {
  try { fs.rmSync(dir, { recursive: true, force: true }) } catch {}
}

function mktemp (prefix) {
  const dir = path.join(os.tmpdir(), 'keet-daemon-test-' + prefix + '-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8))
  fs.mkdirSync(dir, { recursive: true })
  return dir
}

class TestClient {
  constructor (socketPath) {
    this._socketPath = socketPath
    this._pipe = null
    this._framed = null
    this._nextId = 1
    this._pending = new Map()
  }
  connect () {
    return new Promise((resolve, reject) => {
      const pipe = new Pipe(this._socketPath)
      const onErr = (err) => reject(err)
      pipe.once('error', onErr)
      pipe.once('connect', () => {
        pipe.removeListener('error', onErr)
        this._pipe = pipe
        const FS = FramedStream.default || FramedStream
        this._framed = new FS(pipe)
        this._framed.on('data', (buf) => {
          let msg
          try { msg = JSON.parse(buf.toString('utf-8')) } catch { return }
          const p = this._pending.get(msg.id)
          if (!p) return
          this._pending.delete(msg.id)
          if (msg.ok === false || msg.error) p.reject(new Error(msg.error || 'RPC failed'))
          else p.resolve(msg.data !== undefined ? msg.data : msg)
        })
        resolve()
      })
    })
  }
  call (method, params = {}, timeoutMs = 60000) {
    const id = this._nextId++
    const payload = JSON.stringify({ id, method, params })
    return new Promise((resolve, reject) => {
      const timer = setTimeout(() => {
        this._pending.delete(id)
        reject(new Error('RPC timeout: ' + method + ' (' + timeoutMs + 'ms)'))
      }, timeoutMs)
      this._pending.set(id, {
        resolve: (v) => { clearTimeout(timer); resolve(v) },
        reject: (e) => { clearTimeout(timer); reject(e) }
      })
      this._framed.write(Buffer.from(payload, 'utf-8'))
    })
  }
  close () {
    try { this._framed && this._framed.destroy() } catch {}
    try { this._pipe && this._pipe.destroy() } catch {}
  }
}

function log (...args) { console.log('[chat-after-idle]', ...args) }

async function main () {
  const baseDir = mktemp('chat-after-idle')
  const socketPath = path.join(baseDir, 'daemon.sock')
  log('baseDir =', baseDir)

  let daemon
  try {
    daemon = await startDaemon({ storage: baseDir, socketPath })
  } catch (err) {
    log('FAIL startDaemon threw:', err.message)
    rmrf(baseDir)
    return 2
  }

  const client = new TestClient(socketPath)
  try {
    await client.connect()
  } catch (err) {
    log('FAIL UDS connect:', err.message)
    try { await daemon.core.close() } catch {}
    rmrf(baseDir)
    return 2
  }

  // 1. Set up vault / identity
  try {
    await client.call('vault.setup', { password: 'test-pw' }, 90_000)
    log('vault.setup OK')
  } catch (err) {
    log('FAIL vault.setup:', err.message)
    client.close()
    try { await daemon.core.close() } catch {}
    rmrf(baseDir)
    return 2
  }

  // 2. Create a room
  let roomId
  try {
    const created = await client.call('room.create', { title: 'idle-test' }, 30_000)
    // room.create may return the roomId as a bare string, or wrapped in an object
    if (typeof created === 'string') roomId = created
    else roomId = created && (created.roomId || created.id || created.room?.roomId)
    if (!roomId) {
      log('FAIL room.create returned no roomId:', JSON.stringify(created))
      throw new Error('no roomId')
    }
    log('room.create OK roomId =', roomId.slice(0, 12) + '…')
  } catch (err) {
    log('FAIL room.create:', err.message)
    client.close()
    try { await daemon.core.close() } catch {}
    rmrf(baseDir)
    return 2
  }

  // 3. First send — should work
  try {
    await client.call('send', { roomId, text: 'hello pre-idle' }, 15_000)
    log('first send OK')
  } catch (err) {
    log('FAIL first send (unexpected — room is fresh):', err.message)
    client.close()
    try { await daemon.core.close() } catch {}
    rmrf(baseDir)
    return 2
  }

  // 4. Idle wait
  log('idling ' + (IDLE_WAIT_MS / 1000) + 's to cross keet-core GC threshold (~43s)…')
  await new Promise(r => setTimeout(r, IDLE_WAIT_MS))
  log('idle complete')

  // 5. Second send — the moment of truth
  let secondSendError = null
  try {
    await client.call('send', { roomId, text: 'hello post-idle' }, 15_000)
    log('second send OK — bug NOT reproduced (idle GC is not the cause)')
  } catch (err) {
    secondSendError = err
    log('second send THREW:', err.message)
  }

  // Cleanup
  client.close()
  try { await daemon.core.close() } catch {}
  rmrf(baseDir)

  if (secondSendError && /Autobase is closing/.test(secondSendError.message)) {
    log('=== HYPOTHESIS CONFIRMED — second send throws "Autobase is closing"')
    return 0
  }

  if (secondSendError) {
    log('=== second send threw, but not the expected error — hypothesis WRONG')
    log('    actual error:', secondSendError.message)
    return 1
  }

  log('=== HYPOTHESIS REFUTED — second send succeeded after idle, GC is not the trigger')
  return 1
}

main().then(code => process.exit(code)).catch(err => {
  console.error('uncaught:', err)
  process.exit(2)
})