// 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)
})