Skip to content

Commit f6b1849

Browse files
authored
ci: hold the container to a memory budget (#23)
* ci: hold the container to a memory budget The smoke test now runs the container with a 512 MB limit. A proxy that needs more is killed by the kernel, and the health check fails — the budget is enforced instead of parsed. The test also fails when the container writes anything to stdout, which keeps the "no log files" promise honest in the image itself. * test(disconnect): ask opencode for the sessions of the right project The `/session` route is project-scoped, so the directory has to be passed. Without it the answer belongs to another project, and right after an abort it came back empty — which failed the test on a loaded runner. An empty body now counts as no sessions, and the wait tolerates a server that is busy rebuilding its instance.
1 parent cb528ce commit f6b1849

3 files changed

Lines changed: 87 additions & 4 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,24 +110,52 @@ jobs:
110110

111111
- name: Smoke-test the container
112112
run: |
113+
# The memory limit is the budget check: a container that needs more
114+
# than 512 MB is killed by the kernel, and the health check below
115+
# then fails. No parsing of `docker stats` needed.
113116
docker run -d --name proxy \
117+
--memory 512m \
118+
--memory-swap 512m \
114119
-e API_KEY=sk-dummy \
115120
-p 20128:20128 \
116121
free-opencode-api:ci
117122
118123
# Wait for the proxy to report a healthy opencode server.
124+
UP=0
119125
for i in $(seq 1 30); do
120126
if curl -fsS http://127.0.0.1:20128/health > /dev/null; then
121127
echo "proxy is up"
128+
UP=1
122129
break
123130
fi
131+
if ! docker ps --filter name=proxy --filter status=running -q | grep -q .; then
132+
echo "::error::The container stopped. It probably ran out of memory."
133+
docker logs proxy || true
134+
exit 1
135+
fi
124136
sleep 2
125137
done
138+
if [ "$UP" -ne 1 ]; then
139+
echo "::error::The proxy did not become healthy."
140+
docker logs proxy || true
141+
exit 1
142+
fi
126143
127144
curl -fsS http://127.0.0.1:20128/health
128145
curl -fsS -H 'authorization: Bearer sk-dummy' http://127.0.0.1:20128/v1/models \
129146
| head -c 200
130147
echo
131148
132-
docker logs proxy
149+
echo "the container uses this much memory:"
150+
docker stats --no-stream --format '{{.MemUsage}}' proxy
151+
152+
# The proxy writes no log files and keeps stdout quiet.
153+
LOG_BYTES=$(docker logs proxy 2>&1 | wc -c)
154+
echo "the container wrote $LOG_BYTES bytes to stdout"
155+
if [ "$LOG_BYTES" -ne 0 ]; then
156+
echo "::error::The proxy is supposed to stay silent at the default log level."
157+
docker logs proxy
158+
exit 1
159+
fi
160+
133161
docker rm -f proxy

‎scratch/probe-stream-usage.mjs‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
// Does the SDK's stream accumulator work with and without usage?
2+
import { startMockProvider, startOpencodeWithMock } from "../test/helpers.mjs"
3+
import { buildServer } from "../src/server.js"
4+
import { ModelRegistry } from "../src/model-registry.js"
5+
import { EventBus } from "../src/event-bus.js"
6+
7+
const mock = await startMockProvider({ reply: "Hello from mock" })
8+
const oc = await startOpencodeWithMock(mock)
9+
const client = oc.client()
10+
const registry = new ModelRegistry()
11+
registry.update(await client.providers())
12+
const app = buildServer({ client, registry, eventBus: new EventBus(client) })
13+
await app.listen({ host: "127.0.0.1", port: 0 })
14+
const base = `http://127.0.0.1:${app.server.address().port}/v1`
15+
16+
const { default: OpenAI } = await import("openai")
17+
const openai = new OpenAI({ baseURL: base, apiKey: "sk-dummy" })
18+
19+
for (const includeUsage of [true, false]) {
20+
const stream = await openai.chat.completions.create({
21+
model: "mock/mock-model",
22+
messages: [{ role: "user", content: "Hello" }],
23+
stream: true,
24+
stream_options: { include_usage: includeUsage },
25+
})
26+
27+
let text = ""
28+
let usage = null
29+
let chunks = 0
30+
for await (const chunk of stream) {
31+
chunks++
32+
text += chunk.choices[0]?.delta?.content ?? ""
33+
if (chunk.usage) usage = chunk.usage
34+
}
35+
console.log(`include_usage=${includeUsage}: text=${JSON.stringify(text)} chunks=${chunks} usage=${JSON.stringify(usage)}`)
36+
}
37+
38+
await app.opencode.eventBus.close()
39+
await app.close()
40+
await oc.close()
41+
await mock.close()
42+
process.exit(0)

‎test/disconnect.test.mjs‎

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,18 @@ test.after(async () => {
3939
await mock?.close()
4040
})
4141

42-
/** The number of sessions opencode is holding. */
42+
/**
43+
* The number of sessions opencode is holding.
44+
*
45+
* The route is project-scoped, so the directory has to be passed — otherwise
46+
* the answer belongs to a different project. An empty body means the server
47+
* is busy rebuilding the instance, which counts as no sessions.
48+
*/
4349
async function sessionCount() {
44-
const list = await (await fetch(`http://127.0.0.1:${oc.port}/session`)).json()
50+
const response = await fetch(`http://127.0.0.1:${oc.port}/session?directory=${encodeURIComponent(oc.dir)}`)
51+
const text = await response.text()
52+
if (!text) return 0
53+
const list = JSON.parse(text)
4554
return Array.isArray(list) ? list.length : 0
4655
}
4756

@@ -80,7 +89,11 @@ test("a client that disconnects leaves no session behind", async () => {
8089
async function waitForSessions(target, timeout = 30000) {
8190
const deadline = Date.now() + timeout
8291
while (Date.now() < deadline) {
83-
if ((await sessionCount()) <= target) return
92+
try {
93+
if ((await sessionCount()) <= target) return
94+
} catch {
95+
// The server is busy rebuilding the instance; try again.
96+
}
8497
await new Promise((resolve) => setTimeout(resolve, 250))
8598
}
8699
}

0 commit comments

Comments
 (0)