OverviewFilesCommitsIssuesDocsReleasesPeers

knowledge-ratchet commits / bbf0990

test: guard against the CogServer thread-stack leak
CogServer (NetworkServer.cc:169) spawns a joinable std::thread per accepted connection and never joins it until shutdown, so glibc can never unmap that thread's 8MB stack. Measured cost per connection: 1.92 VMAs, 15.9 KB RSS, 7.68 MB VSZ -- permanently. cogserver.js opens one connection per Scheme expression, and isCogServerUp() burns an extra probe socket per caller, so hook traffic drove ~9 conn/s and ~5.3 GB of leaked stacks. This test asserts both halves of the fix: 1. correctness -- concurrent evalScheme() calls each get their own answer, guarding the FIFO queue a shared socket requires (callers use Promise.all, e.g. query.js:313-315, 468-475) 2. reuse -- a burst of 10 evals costs the server <= 6 VMAs Written test-first: currently RED against the unpooled client (57 VMAs leaked, ~29.7 connections; correctness passes). Refs: atomspace/hypercore-kb#2, atomspace/opencog-cogserver#1 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
bbf099076153aec68f53d309877b8778c87dd075
by plankatron <fleeky@gmail.com> · 2026-08-12T02:22:06.000Z

Changed files (1) +125 -0

atomspace/test/connection-reuse.test.js +125 -0
……@@ -0,0 +1,125 @@
1#!/usr/bin/env node
2
3/**
4 * connection-reuse.test.js — Guards against the CogServer thread-stack leak
5 *
6 * CogServer (NetworkServer.cc:169) spawns a joinable std::thread per accepted
7 * connection and never joins it until shutdown, so glibc can never unmap that
8 * thread's 8MB stack. Every connection permanently costs the server ~2 VMAs,
9 * ~16KB RSS and ~8MB of address space. A client that opens one connection per
10 * Scheme expression therefore leaks the server to death.
11 *
12 * This test asserts two things:
13 * 1. CORRECTNESS — concurrent evalScheme() calls each get their own answer.
14 * (A shared connection must serialize requests; interleaving them on one
15 * socket would scramble the replies.)
16 * 2. REUSE — a burst of evals costs the server at most one connection.
17 *
18 * Usage: node atomspace/test/connection-reuse.test.js
19 * Requires: CogServer running on port 17001
20 */
21
22const fs = require('fs')
23const { isCogServerUp, evalScheme } = require('../cogserver')
24
25// One connection = ~1.92 VMAs (8MB stack + guard page). Allow one connection
26// plus slack for unrelated server activity during the run.
27const MAX_LEAKED_VMAS = 6
28
29// cogserver renames its main thread to "cogserv:loop" via prctl, so comm-based
30// lookup (pgrep -x cogserver) finds nothing. Match on argv[0] instead.
31function cogserverPid () {
32 for (const entry of fs.readdirSync('/proc')) {
33 if (!/^\d+$/.test(entry)) continue
34 try {
35 const argv0 = fs.readFileSync(`/proc/${entry}/cmdline`, 'utf8').split('\0')[0]
36 if (argv0 && argv0.split('/').pop() === 'cogserver') return entry
37 } catch { /* process exited, or not ours */ }
38 }
39 return null
40}
41
42function vmaCount (pid) {
43 try {
44 return fs.readFileSync(`/proc/${pid}/maps`, 'utf8').split('\n').length - 1
45 } catch { return null }
46}
47
48// Distinct answers, so a scrambled reply cannot accidentally pass.
49const CASES = [
50 ['(+ 1 1)', '2'],
51 ['(* 6 7)', '42'],
52 ['(- 10 3)', '7'],
53 ['(+ 100 23)', '123'],
54 ['(* 11 11)', '121'],
55 ['(- 50 8)', '42'],
56 ['(+ 2 3)', '5'],
57 ['(* 9 9)', '81'],
58 ['(- 99 9)', '90'],
59 ['(+ 7 0)', '7']
60]
61
62function extract (raw) {
63 // Guile echoes results as "$N = VALUE"; tolerate a bare value too.
64 if (raw === null || raw === undefined) return null
65 const m = String(raw).match(/\$\d+\s*=\s*(\S+)/)
66 return m ? m[1] : String(raw).trim()
67}
68
69async function main () {
70 const pid = cogserverPid()
71 if (!pid) {
72 console.log('SKIP: cogserver not running')
73 process.exit(0)
74 }
75 if (!await isCogServerUp()) {
76 console.log('SKIP: cogserver not reachable on 17001')
77 process.exit(0)
78 }
79
80 const before = vmaCount(pid)
81 let failures = 0
82
83 // Sequential calls.
84 for (const [code, want] of CASES.slice(0, 5)) {
85 const got = extract(await evalScheme(code))
86 if (got !== want) {
87 console.log(` FAIL sequential ${code} -> got ${JSON.stringify(got)}, want ${want}`)
88 failures++
89 }
90 }
91
92 // Concurrent calls — the case that breaks a naive shared socket.
93 const concurrent = CASES.slice(5)
94 const results = await Promise.all(concurrent.map(([code]) => evalScheme(code)))
95 concurrent.forEach(([code, want], i) => {
96 const got = extract(results[i])
97 if (got !== want) {
98 console.log(` FAIL concurrent ${code} -> got ${JSON.stringify(got)}, want ${want}`)
99 failures++
100 }
101 })
102
103 const after = vmaCount(pid)
104 const leaked = after - before
105 const evals = CASES.length
106
107 console.log(`\ncogserver pid ${pid}`)
108 console.log(` evals issued : ${evals} (+1 isCogServerUp probe)`)
109 console.log(` VMAs before : ${before.toLocaleString()}`)
110 console.log(` VMAs after : ${after.toLocaleString()}`)
111 console.log(` VMAs leaked : ${leaked} (~${(leaked / 1.92).toFixed(1)} connections)`)
112 console.log(` budget : <= ${MAX_LEAKED_VMAS}`)
113 console.log(` correctness : ${failures === 0 ? 'all answers correct' : failures + ' wrong answer(s)'}`)
114
115 const leakOk = leaked <= MAX_LEAKED_VMAS
116 if (!leakOk) console.log(`\nFAIL: leaked ${leaked} VMAs, expected <= ${MAX_LEAKED_VMAS} (one connection reused)`)
117 if (failures === 0 && leakOk) {
118 console.log('\nPASS')
119 process.exit(0)
120 }
121 console.log('\nFAIL')
122 process.exit(1)
123}
124
125main().catch((e) => { console.error('ERROR', e); process.exit(1) })