ghostbus 0.5.5 → 0.5.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/README.md +1 -1
- package/docs/hosted.md +61 -0
- package/package.json +1 -1
- package/src/core.mjs +1 -1
- package/src/handler.mjs +125 -5
- package/src/hosted-server.mjs +13 -1
- package/src/http-server.mjs +9 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.6 — 2026-10-11
|
|
4
|
+
|
|
5
|
+
Security hardening, round 5 — load & abuse resilience: what happens when many agents share one process for a long time. This round built the first repeatable load bench before changing anything, and the honest headline is that load broke no correctness property — zero errors, zero lost writes, exact reconciliation at every size. What it (and the audit around it) found instead: an unbounded subscription surface, missing server timeouts whose platform defaults do not do what their names suggest, and a measured, structural scaling limit that is now documented instead of discovered by operators.
|
|
6
|
+
|
|
7
|
+
- **Load bench (`scripts/load-bench.mjs`, new)** — boots the real hosted server with per-agent tokens enforced and drives N agents through mixed operations (register, heartbeat, send, inbox, task create→claim→complete, file put), reporting ops, errors, and p50/p95 latency per op class, plus a hard reconciliation: messages sent == messages stored, tasks created == tasks completed, files put == files stored. Baseline on the development machine (Node 24): **25 agents × 30 s → 2,506 ops, ~83 ops/s, 0 errors**, p50 ~280–360 ms / p95 ~390–595 ms per op class, reconciled exactly (650 messages, 300/300 tasks, 300 files); **25 agents × 60 s → 3,438 ops, ~57 ops/s, 0 errors**, also exact. Repeat 30 s runs on this shared VM varied between ~55 and ~112 ops/s with machine load; the invariants across every run were 0 errors and exact reconciliation. A reduced smoke (6 agents × 3 s) runs in the suite. One bench disclosure: at the shipped 300 req/min per-IP limiter, a single-IP bench measures the limiter, not the bus, so the bench raises that one knob for itself (printed in its output); every other limit is at its shipped default.
|
|
8
|
+
- **What the load actually showed** — throughput declines as a workspace's state grows (~83 → ~57 ops/s across the captured 30 s/60 s pair as the state file grew ~0.9 → ~1.2 MB, p95 latency roughly doubling) because every operation loads and re-saves the whole state file (O(state) per op) and all operations serialize on the per-workspace lock: with 25 agents queued, most of the p50 is queue wait. Memory plateaued (RSS oscillated in a ~140–260 MB band under sustained load — garbage collection keeping up with per-op state copies), it did not grow with op count. No fix is claimed for the scaling shape: it is the single-file store's documented trade-off, now with numbers, in `docs/hosted.md` ("Capacity & limits"). Trims (10k messages / 5k events / per-file history) held throughout.
|
|
9
|
+
- **SSE subscriptions were unbounded (fixed)** — before this round, any number of `/api/stream` connections could be held per workspace, each one a permanent bus listener plus a 25 s ping timer, reaped only on socket close. There are now caps, checked before the stream opens: **100 per workspace** (`GHOSTBUS_MAX_STREAMS_PER_WORKSPACE`) and **1000 per server** (`GHOSTBUS_MAX_STREAMS_TOTAL`), with a clean `429` JSON refusal over the cap. Cleanup is now idempotent across close/error/write-failure paths, and the suite proves the cap, that a freed slot reopens, and that bus listener counts return to exactly zero after stream churn — no listener leaks.
|
|
10
|
+
- **Explicit server timeouts on both servers** — slow-loris room is closed. Both HTTP servers now take commented deadlines (defaults: headers 10 s, request body 30 s, keep-alive 5 s; overridable via `GHOSTBUS_HEADERS_TIMEOUT_MS` / `GHOSTBUS_REQUEST_TIMEOUT_MS`). The implementation notes are in the code because the platform defaults mislead: Node's own header enforcement was probed to leave a partial-headers socket alive past 15 s in this configuration, and its `requestTimeout` semantics proved unsafe to reason about for long-lived responses, so that property is pinned to 0 and both deadlines are enforced by a small connection-level guard that only ever destroys a socket whose request is genuinely incomplete. The guard's first cut armed its body timer on `req.complete` — which stays false for a bodyless GET nobody reads, exactly the `/api/stream` shape — and killed live streams in testing; the shipped guard arms only on a *declared* body (Content-Length / Transfer-Encoding). The suite proves all of it at tightened deadlines (400 ms / 800 ms): a partial-headers socket dies at the header deadline, a slow body drip dies at the body deadline, and an SSE stream plus a 2 s long-poll outlive both.
|
|
11
|
+
- **Recovery drill (new suite proof)** — while three agents write against a hosted FileStore workspace, the server is SIGKILLed mid-load and restarted on the same data directory. Assertions: the state file parses (temp-file + atomic rename means a kill strands at most an inert `.tmp`), every operation already answered HTTP 200 is present after restart, each agent's stored messages form a contiguous prefix of its attempts (nothing half-applied), acked files are present, and the restarted server's counts agree with the file. This is the durability claim for the JSON store, stated at its true scope: single file, one writer process, last-writer semantics per op.
|
|
12
|
+
- **Long-poll pile-up (new suite proof)** — 25 concurrent `/api/wait` holders all wake with the event on a single broadcast (none hang, none miss it), and 10 idle waits all resolve on their own timeout with `waited: true` and no events, with no unhandled rejections in the server log.
|
|
13
|
+
- **Limiter hygiene** — the per-IP/workspace rate limiter is now tunable (`GHOSTBUS_RATE_LIMIT_PER_MIN`, default unchanged at 300) and its over-limit message reports the live value; the limiter map and the hosted failed-auth map prune stale buckets opportunistically instead of only ever growing under address churn.
|
|
14
|
+
- **Tests** — five new checks (69 in the main suite, was 64): bench smoke with exact reconciliation, server timeouts (loris + drip + SSE/long-poll survival), SSE caps + reaping, wait pile-up, and the SIGKILL recovery drill. The suite now takes ~22 s because three of the new checks are real-time proofs (a 3 s bench window, a 2 s long-poll, a 1.2 s kill drill) — that time is the evidence, not overhead. Serverless stays 6/6 and both demos pass.
|
|
15
|
+
- **Scope boundary, honestly** — what did NOT change: the storage format and every API shape are untouched; the local trust model is untouched; the bench figures are one machine's evidence, not a hosted SLA; and the networked multi-writer store remains future work — this round measured the single-file store's limits rather than relitigating them.
|
|
16
|
+
|
|
3
17
|
## 0.5.5 — 2026-10-11
|
|
4
18
|
|
|
5
19
|
Security hardening, round 4 — the client side of the wire. Rounds 2–3 hardened what the server stores and how it authenticates; this round fuzzed what a client can *send*: MCP tool arguments over stdio JSON-RPC, board payloads end-to-end, and raw HTTP parameters. One real crash, a class of silent state corruption, and a set of numeric edge cases were found and fixed. Several surfaces audited clean and are now pinned by tests instead — the entry says which, as usual.
|
package/README.md
CHANGED
|
@@ -23,7 +23,7 @@ Built and in use: 29 MCP tools across presence, messages, tasks, a shared worksp
|
|
|
23
23
|
```bash
|
|
24
24
|
git clone https://github.com/littlestjames82-sys/ghostbus.git ghostbus && cd ghostbus
|
|
25
25
|
node examples/two-agent-demo.mjs # watch a planner and a builder cooperate
|
|
26
|
-
node test.mjs #
|
|
26
|
+
node test.mjs # 69 checks: core, tokens, capsules, dependencies, sync, persistence, concurrency, MCP stdio, HTTP, SSE, CLI, hosted security, hostile input, input fuzz, load & resilience
|
|
27
27
|
node examples/three-agent-team.mjs # planner + builder + reviewer, no human relaying
|
|
28
28
|
```
|
|
29
29
|
|
package/docs/hosted.md
CHANGED
|
@@ -189,6 +189,67 @@ including MCP clients and any agent driving one:
|
|
|
189
189
|
and referers. `/health` and `/probe` return exactly their documented
|
|
190
190
|
counts-only shapes.
|
|
191
191
|
|
|
192
|
+
## Capacity & limits (measured, v0.5.6)
|
|
193
|
+
|
|
194
|
+
First published load figures, from `scripts/load-bench.mjs` — a repeatable
|
|
195
|
+
bench that boots the real hosted server (per-agent tokens enforced) and
|
|
196
|
+
drives N agents through mixed register/heartbeat/send/inbox/task/file
|
|
197
|
+
operations, then reconciles the final state **exactly** (messages sent ==
|
|
198
|
+
messages stored, tasks created == tasks completed, files put == files
|
|
199
|
+
stored). One development machine (Node 24, local disk); treat these as
|
|
200
|
+
evidence about the shape of the limits, not as a hosted SLA.
|
|
201
|
+
|
|
202
|
+
| run | ops | throughput | errors | latency (p50 / p95) |
|
|
203
|
+
|---|---|---|---|---|
|
|
204
|
+
| 25 agents × 30 s | 2,506 | ~83 ops/s | 0 | ~280–360 ms / ~390–595 ms per op class |
|
|
205
|
+
| 25 agents × 60 s | 3,438 | ~57 ops/s | 0 | ~315–470 ms / ~910–1,870 ms per op class |
|
|
206
|
+
|
|
207
|
+
Repeat runs vary with machine load — five 30 s runs on the shared
|
|
208
|
+
development VM landed between ~55 and ~112 ops/s. What did not vary, in
|
|
209
|
+
any run: **0 errors, and exact reconciliation.**
|
|
210
|
+
|
|
211
|
+
What the numbers mean:
|
|
212
|
+
|
|
213
|
+
- **Correctness holds under concurrency.** Zero errors and exact
|
|
214
|
+
reconciliation at every size — the per-workspace lock serializes
|
|
215
|
+
operations, so nothing is lost or double-applied.
|
|
216
|
+
- **Throughput is bounded by design, and declines as a workspace grows.**
|
|
217
|
+
Every operation loads and re-saves the workspace's whole state file, so
|
|
218
|
+
per-operation cost is O(state size), and operations queue behind each
|
|
219
|
+
other: with 25 agents active, most of the ~300 ms p50 is queue wait, not
|
|
220
|
+
work. The 60 s run is slower per op than the 30 s run because its state
|
|
221
|
+
file grew from ~0.9 MB to ~1.2 MB during the run. History trims (10k
|
|
222
|
+
messages, 5k events, per-file history) bound how large that cost can
|
|
223
|
+
get, but a hot workspace is a real cost center — split hot work into
|
|
224
|
+
more workspaces rather than feeding one forever.
|
|
225
|
+
- **Memory plateaus.** Under sustained load the server's RSS climbed,
|
|
226
|
+
then oscillated in a ~140–260 MB band (garbage collection keeping up
|
|
227
|
+
with per-operation state copies). It did not grow linearly with the
|
|
228
|
+
operation count; no leak was found.
|
|
229
|
+
- **Durability under a hard kill is proven, not assumed.** The suite's
|
|
230
|
+
recovery drill SIGKILLs the server mid-load and restarts on the same
|
|
231
|
+
data directory: the state file parses (writes are temp-file + atomic
|
|
232
|
+
rename, so a kill can strand at most an inert `.tmp` file), every
|
|
233
|
+
operation the server had already answered with HTTP 200 is present, and
|
|
234
|
+
each agent's stored messages form a contiguous prefix of what it
|
|
235
|
+
attempted — nothing half-applied.
|
|
236
|
+
|
|
237
|
+
Abuse limits an operator can tune (environment variables):
|
|
238
|
+
|
|
239
|
+
| variable | default | meaning |
|
|
240
|
+
|---|---|---|
|
|
241
|
+
| `GHOSTBUS_MAX_STREAMS_PER_WORKSPACE` | 100 | concurrent `/api/stream` subscribers per workspace; over the cap → `429` |
|
|
242
|
+
| `GHOSTBUS_MAX_STREAMS_TOTAL` | 1000 | concurrent streams across the whole server (hosted) |
|
|
243
|
+
| `GHOSTBUS_HEADERS_TIMEOUT_MS` | 10000 | deadline for a client to deliver complete request headers |
|
|
244
|
+
| `GHOSTBUS_REQUEST_TIMEOUT_MS` | 30000 | deadline to deliver a declared request body (never bounds a response — SSE and long-poll outlive it by design) |
|
|
245
|
+
| `GHOSTBUS_RATE_LIMIT_PER_MIN` | 300 | per-IP, per-workspace request budget (the v0.4 limiter, now tunable) |
|
|
246
|
+
|
|
247
|
+
When to scale out: one process serving a handful of team-sized workspaces
|
|
248
|
+
is the design point. If a single workspace's sustained op rate or state
|
|
249
|
+
size becomes the bottleneck, the honest answer today is "more, smaller
|
|
250
|
+
workspaces" — the networked multi-writer store that would lift the
|
|
251
|
+
single-file ceiling is documented future work, not a flag you can flip.
|
|
252
|
+
|
|
192
253
|
## Option B — serverless on Netlify (Blobs)
|
|
193
254
|
|
|
194
255
|
`deploy/netlify/` contains a Netlify Function implementing the same multi-workspace semantics over Netlify Blobs (strong consistency):
|
package/package.json
CHANGED
package/src/core.mjs
CHANGED
|
@@ -14,7 +14,7 @@ import path from 'node:path';
|
|
|
14
14
|
import crypto from 'node:crypto';
|
|
15
15
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
16
16
|
|
|
17
|
-
export const GHOSTBUS_VERSION = '0.5.
|
|
17
|
+
export const GHOSTBUS_VERSION = '0.5.6';
|
|
18
18
|
const sha256 = (s) => crypto.createHash('sha256').update(String(s)).digest('hex');
|
|
19
19
|
// Timing-safe equality for hex digests (v0.5.3): secret comparisons must not
|
|
20
20
|
// short-circuit on the first differing character the way === does.
|
package/src/handler.mjs
CHANGED
|
@@ -75,16 +75,117 @@ export const HTML_SECURITY_HEADERS = {
|
|
|
75
75
|
|
|
76
76
|
const TOO_LARGE = Symbol('body-too-large');
|
|
77
77
|
|
|
78
|
+
// Per-IP request limiter (per workspace — one handler serves one workspace).
|
|
79
|
+
// The default (300/min) is unchanged since v0.3; GHOSTBUS_RATE_LIMIT_PER_MIN
|
|
80
|
+
// is an operator knob for two legitimate cases: many agents behind one NAT
|
|
81
|
+
// address sharing a single source IP, and load benchmarking, where the
|
|
82
|
+
// limiter — not the bus — would otherwise be the first ceiling (v0.5.6).
|
|
83
|
+
const rateLimitPerMin = () => {
|
|
84
|
+
const n = Number(process.env.GHOSTBUS_RATE_LIMIT_PER_MIN);
|
|
85
|
+
return Number.isFinite(n) && n >= 1 ? Math.floor(n) : 300;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// Server timeouts (v0.5.6). Three different clocks, three different jobs:
|
|
89
|
+
// - headersMs bounds how long a client may take to deliver complete request
|
|
90
|
+
// HEADERS (the classic slow-loris drip).
|
|
91
|
+
// - requestMs bounds how long it may take to deliver the request BODY after
|
|
92
|
+
// that. It must never bound a response: /api/stream and /api/wait are
|
|
93
|
+
// long-lived BY DESIGN.
|
|
94
|
+
// - keepAliveMs is Node's own idle keep-alive lifetime.
|
|
95
|
+
// Why this is not just "set the three Node properties": Node's own
|
|
96
|
+
// enforcement of both knobs was probed on Node 24 before this design was
|
|
97
|
+
// chosen, and neither is a deterministic drop-in for the deadlines above.
|
|
98
|
+
// - server.headersTimeout IS set (it only governs the header phase), but
|
|
99
|
+
// its enforcement rides on Node's socket-timeout machinery: with the
|
|
100
|
+
// default server.timeout of 0, a partial-headers socket was probed to
|
|
101
|
+
// survive 15 s at headersTimeout=1000.
|
|
102
|
+
// - server.requestTimeout is pinned to 0: its semantics proved impossible
|
|
103
|
+
// to reason about safely for long-lived responses (a handler that
|
|
104
|
+
// writes constantly masks its clock entirely), and the deadline it is
|
|
105
|
+
// documented for — receiving the request — is exactly the one the
|
|
106
|
+
// guard below enforces deterministically instead.
|
|
107
|
+
// The guard: arm the header deadline on connect, clear it when headers
|
|
108
|
+
// land, bound a *declared* body from there, re-arm while the socket sits
|
|
109
|
+
// between keep-alive requests. server.timeout stays 0 on purpose — socket
|
|
110
|
+
// *inactivity* is not a deadline here, because a long-poll legitimately
|
|
111
|
+
// idles for up to 55 s and an SSE stream between events for longer.
|
|
112
|
+
export const SERVER_TIMEOUT_DEFAULTS = { headersMs: 10_000, requestMs: 30_000, keepAliveMs: 5_000 };
|
|
113
|
+
export function applyServerTimeouts(server, opts = {}) {
|
|
114
|
+
const headersMs = opts.headersMs ?? SERVER_TIMEOUT_DEFAULTS.headersMs;
|
|
115
|
+
const requestMs = opts.requestMs ?? SERVER_TIMEOUT_DEFAULTS.requestMs;
|
|
116
|
+
const keepAliveMs = opts.keepAliveMs ?? SERVER_TIMEOUT_DEFAULTS.keepAliveMs;
|
|
117
|
+
server.headersTimeout = headersMs;
|
|
118
|
+
server.requestTimeout = 0; // see above — Node's clock kills live streams; the guard owns this deadline
|
|
119
|
+
server.keepAliveTimeout = keepAliveMs;
|
|
120
|
+
server.timeout = 0; // see above — inactivity is not the deadline
|
|
121
|
+
server.on('connection', (socket) => {
|
|
122
|
+
const armHeaderDeadline = () => {
|
|
123
|
+
clearTimeout(socket._gbHeaderTimer);
|
|
124
|
+
socket._gbHeaderTimer = setTimeout(() => socket.destroy(), headersMs);
|
|
125
|
+
socket._gbHeaderTimer.unref?.();
|
|
126
|
+
};
|
|
127
|
+
socket._gbArmHeaderDeadline = armHeaderDeadline;
|
|
128
|
+
armHeaderDeadline();
|
|
129
|
+
socket.on('close', () => clearTimeout(socket._gbHeaderTimer));
|
|
130
|
+
});
|
|
131
|
+
server.on('request', (req, res) => {
|
|
132
|
+
const socket = req.socket;
|
|
133
|
+
clearTimeout(socket._gbHeaderTimer); // headers are in — that deadline is met
|
|
134
|
+
clearTimeout(socket._gbBodyTimer);
|
|
135
|
+
// Bound the body ONLY when one was declared. req.complete cannot be
|
|
136
|
+
// the trigger: for a bodyless GET whose stream nobody reads (exactly
|
|
137
|
+
// the /api/stream and /api/wait shape) it stays false indefinitely, so
|
|
138
|
+
// a timer armed on it would destroy healthy long-lived exchanges —
|
|
139
|
+
// the first cut of this guard did precisely that to SSE streams.
|
|
140
|
+
const declaresBody = Number(req.headers['content-length'] || 0) > 0 || 'transfer-encoding' in req.headers;
|
|
141
|
+
if (declaresBody && !req.complete) {
|
|
142
|
+
socket._gbBodyTimer = setTimeout(() => socket.destroy(), requestMs);
|
|
143
|
+
socket._gbBodyTimer.unref?.();
|
|
144
|
+
const clear = () => clearTimeout(socket._gbBodyTimer);
|
|
145
|
+
req.on('end', clear);
|
|
146
|
+
req.on('close', clear);
|
|
147
|
+
res.on('finish', clear);
|
|
148
|
+
res.on('close', clear);
|
|
149
|
+
}
|
|
150
|
+
// Between keep-alive requests the socket owes us a fresh set of
|
|
151
|
+
// headers: re-arm the header deadline when this exchange finishes so a
|
|
152
|
+
// stalled *next* request is bounded too. A still-open response (SSE)
|
|
153
|
+
// never emits 'finish', and its request completed on arrival, so no
|
|
154
|
+
// timer is ever pending against a live stream or long-poll.
|
|
155
|
+
res.on('finish', () => { if (!socket.destroyed && socket._gbArmHeaderDeadline) socket._gbArmHeaderDeadline(); });
|
|
156
|
+
res.on('close', () => clearTimeout(socket._gbHeaderTimer));
|
|
157
|
+
});
|
|
158
|
+
return server;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// SSE stream caps (v0.5.6): before this round a workspace's /api/stream
|
|
162
|
+
// subscribers were bounded by nothing but the request limiter — each one
|
|
163
|
+
// holds a bus listener, a socket, and a 25 s ping interval, forever, and a
|
|
164
|
+
// buggy client reconnect-looping could pile them up without limit. Caps are
|
|
165
|
+
// generous against real teams (an agent holds at most a stream or two) and
|
|
166
|
+
// refuse cleanly with 429 past them. The per-process total spans every
|
|
167
|
+
// handler in the process, which is what protects the hosted server.
|
|
168
|
+
const streamCap = (envName, def) => {
|
|
169
|
+
const n = Number(process.env[envName]);
|
|
170
|
+
return Number.isFinite(n) && n >= 1 ? Math.floor(n) : def;
|
|
171
|
+
};
|
|
172
|
+
let activeStreamsTotal = 0;
|
|
173
|
+
|
|
78
174
|
export function createBusHandler({ id = 'default', bus, key = '' }) {
|
|
79
175
|
const buckets = new Map();
|
|
176
|
+
const rateLimit = rateLimitPerMin();
|
|
80
177
|
const rateOk = (req) => {
|
|
81
178
|
const ip = (req.socket && req.socket.remoteAddress) || 'unknown';
|
|
82
179
|
const t = Date.now();
|
|
180
|
+
// Opportunistic sweep: one bucket per source IP per minute would
|
|
181
|
+
// otherwise accumulate forever on a long-lived server (v0.5.6).
|
|
182
|
+
if (buckets.size > 4096) { for (const [k, v] of buckets) if (t - v.start > 60_000) buckets.delete(k); }
|
|
83
183
|
let b = buckets.get(ip);
|
|
84
184
|
if (!b || t - b.start > 60_000) { b = { start: t, count: 0 }; buckets.set(ip, b); }
|
|
85
185
|
b.count += 1;
|
|
86
|
-
return b.count <=
|
|
186
|
+
return b.count <= rateLimit;
|
|
87
187
|
};
|
|
188
|
+
let activeStreams = 0; // this workspace's live /api/stream subscribers
|
|
88
189
|
const keyOk = (req) => {
|
|
89
190
|
if (!key) return true;
|
|
90
191
|
const given = req.headers['x-bus-key'] || String(req.headers.authorization || '').replace(/^Bearer\s+/i, '');
|
|
@@ -99,7 +200,7 @@ export function createBusHandler({ id = 'default', bus, key = '' }) {
|
|
|
99
200
|
res.writeHead(status, { 'content-type': type }); res.end(body);
|
|
100
201
|
};
|
|
101
202
|
if (!rateOk(req) && url.pathname !== '/health' && url.pathname !== '/probe') {
|
|
102
|
-
return send(429, { error:
|
|
203
|
+
return send(429, { error: `rate limit: ${rateLimit} requests/min per IP per workspace` });
|
|
103
204
|
}
|
|
104
205
|
const send413 = () => {
|
|
105
206
|
try {
|
|
@@ -124,15 +225,34 @@ export function createBusHandler({ id = 'default', bus, key = '' }) {
|
|
|
124
225
|
if (req.method === 'GET' && url.pathname === '/api/stream') {
|
|
125
226
|
const agent = url.searchParams.get('agent');
|
|
126
227
|
if (!agent) return send(400, { error: 'agent query param required' });
|
|
228
|
+
// Stream caps (v0.5.6): refuse BEFORE opening the stream — a 429 the
|
|
229
|
+
// client can back off from, not a socket that dies later.
|
|
230
|
+
const maxPerWorkspace = streamCap('GHOSTBUS_MAX_STREAMS_PER_WORKSPACE', 100);
|
|
231
|
+
const maxTotal = streamCap('GHOSTBUS_MAX_STREAMS_TOTAL', 1000);
|
|
232
|
+
if (activeStreams >= maxPerWorkspace) return send(429, { error: `too many event streams for this workspace (limit ${maxPerWorkspace})` });
|
|
233
|
+
if (activeStreamsTotal >= maxTotal) return send(429, { error: `too many event streams on this server (limit ${maxTotal})` });
|
|
234
|
+
activeStreams += 1; activeStreamsTotal += 1;
|
|
127
235
|
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' });
|
|
128
236
|
res.write(`event: ready\ndata: ${JSON.stringify({ agent, workspace: id, version: GHOSTBUS_VERSION })}\n\n`);
|
|
237
|
+
let cleaned = false;
|
|
129
238
|
const off = bus.onEvent((ev) => {
|
|
130
239
|
const forMe = ev.type === 'message.send' ? (ev.to === agent || ev.to === '*' || ev.actor === agent) : true;
|
|
131
240
|
if (!forMe) return;
|
|
132
|
-
res.write(`event: ${ev.type}\ndata: ${JSON.stringify(ev)}\n\n`);
|
|
241
|
+
try { res.write(`event: ${ev.type}\ndata: ${JSON.stringify(ev)}\n\n`); } catch { cleanup(); }
|
|
133
242
|
});
|
|
134
|
-
const ping = setInterval(() => res.write(': ping\n\n'), 25000);
|
|
135
|
-
|
|
243
|
+
const ping = setInterval(() => { try { res.write(': ping\n\n'); } catch { cleanup(); } }, 25000);
|
|
244
|
+
// Reaping (v0.5.6): whichever side of the connection dies first —
|
|
245
|
+
// request close, response close, or a write into a dead socket —
|
|
246
|
+
// the listener, the ping, and both cap slots are released exactly
|
|
247
|
+
// once, so churn can neither leak listeners nor strand cap slots.
|
|
248
|
+
function cleanup() {
|
|
249
|
+
if (cleaned) return; cleaned = true;
|
|
250
|
+
clearInterval(ping); off();
|
|
251
|
+
activeStreams -= 1; activeStreamsTotal -= 1;
|
|
252
|
+
}
|
|
253
|
+
req.on('close', cleanup);
|
|
254
|
+
res.on('close', cleanup);
|
|
255
|
+
res.on('error', cleanup);
|
|
136
256
|
return;
|
|
137
257
|
}
|
|
138
258
|
|
package/src/hosted-server.mjs
CHANGED
|
@@ -37,7 +37,7 @@ import fs from 'node:fs';
|
|
|
37
37
|
import path from 'node:path';
|
|
38
38
|
import crypto from 'node:crypto';
|
|
39
39
|
import { createBus, FileStore, GHOSTBUS_VERSION, validateSnapshot } from './core.mjs';
|
|
40
|
-
import { createBusHandler, keyMatches, hashMatches, readJsonBody, MAX_BODY_BYTES, HTML_SECURITY_HEADERS } from './handler.mjs';
|
|
40
|
+
import { createBusHandler, keyMatches, hashMatches, readJsonBody, MAX_BODY_BYTES, HTML_SECURITY_HEADERS, applyServerTimeouts, SERVER_TIMEOUT_DEFAULTS } from './handler.mjs';
|
|
41
41
|
|
|
42
42
|
const argv = process.argv.slice(2);
|
|
43
43
|
const argVal = (flag) => { const i = argv.indexOf(flag); return i >= 0 ? argv[i + 1] : null; };
|
|
@@ -145,6 +145,9 @@ const authThrottled = (ip) => {
|
|
|
145
145
|
};
|
|
146
146
|
const noteAuthFail = (ip) => {
|
|
147
147
|
const t = Date.now();
|
|
148
|
+
// Opportunistic sweep (v0.5.6): one entry per attacking IP per window
|
|
149
|
+
// would otherwise accumulate forever on a long-lived server.
|
|
150
|
+
if (authFails.size > 4096) { for (const [k, v] of authFails) if (t - v.start > AUTH_FAIL_WINDOW_MS) authFails.delete(k); }
|
|
148
151
|
let b = authFails.get(ip);
|
|
149
152
|
if (!b || t - b.start > AUTH_FAIL_WINDOW_MS) { b = { start: t, count: 0 }; authFails.set(ip, b); }
|
|
150
153
|
b.count += 1;
|
|
@@ -316,6 +319,15 @@ const server = http.createServer(async (req, res) => {
|
|
|
316
319
|
}
|
|
317
320
|
});
|
|
318
321
|
|
|
322
|
+
// Explicit server timeouts (v0.5.6): slow-loris header/body deadlines, set
|
|
323
|
+
// here rather than inherited from Node defaults — see applyServerTimeouts
|
|
324
|
+
// in handler.mjs for the scoping (long-lived streams are never bounded).
|
|
325
|
+
const timeoutOpt = (envName, def) => { const n = Number(process.env[envName]); return Number.isFinite(n) && n > 0 ? Math.floor(n) : def; };
|
|
326
|
+
applyServerTimeouts(server, {
|
|
327
|
+
headersMs: timeoutOpt('GHOSTBUS_HEADERS_TIMEOUT_MS', SERVER_TIMEOUT_DEFAULTS.headersMs),
|
|
328
|
+
requestMs: timeoutOpt('GHOSTBUS_REQUEST_TIMEOUT_MS', SERVER_TIMEOUT_DEFAULTS.requestMs),
|
|
329
|
+
});
|
|
330
|
+
|
|
319
331
|
server.listen(port, () => {
|
|
320
332
|
process.stderr.write(`ghostbus HOSTED v${GHOSTBUS_VERSION} · http://127.0.0.1:${port} · data=${dataDir} · workspaces=${Object.keys(loadRegistry().workspaces).length} · agentTokens=${REQUIRE_TOKENS ? 'enforced' : 'issued, not enforced'}\n`);
|
|
321
333
|
});
|
package/src/http-server.mjs
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import http from 'node:http';
|
|
16
16
|
import { createBus, MemoryStore, FileStore, GHOSTBUS_VERSION } from './core.mjs';
|
|
17
|
-
import { createBusHandler } from './handler.mjs';
|
|
17
|
+
import { createBusHandler, applyServerTimeouts, SERVER_TIMEOUT_DEFAULTS } from './handler.mjs';
|
|
18
18
|
|
|
19
19
|
const argv = process.argv.slice(2);
|
|
20
20
|
const argVal = (flag) => { const i = argv.indexOf(flag); return i >= 0 ? argv[i + 1] : null; };
|
|
@@ -32,6 +32,14 @@ const server = http.createServer((req, res) => {
|
|
|
32
32
|
const url = new URL(req.url, 'http://localhost');
|
|
33
33
|
handle(req, res, url).catch((e) => { process.stderr.write(`ghostbus relay: internal error: ${(e && e.stack) || e}\n`); try { res.writeHead(500, { 'content-type': 'application/json' }); res.end(JSON.stringify({ error: 'internal error' })); } catch {} });
|
|
34
34
|
});
|
|
35
|
+
// Explicit server timeouts (v0.5.6): slow-loris header/body deadlines, set
|
|
36
|
+
// here rather than inherited from Node defaults — see applyServerTimeouts
|
|
37
|
+
// in handler.mjs for the scoping (long-lived streams are never bounded).
|
|
38
|
+
const timeoutOpt = (envName, def) => { const n = Number(process.env[envName]); return Number.isFinite(n) && n > 0 ? Math.floor(n) : def; };
|
|
39
|
+
applyServerTimeouts(server, {
|
|
40
|
+
headersMs: timeoutOpt('GHOSTBUS_HEADERS_TIMEOUT_MS', SERVER_TIMEOUT_DEFAULTS.headersMs),
|
|
41
|
+
requestMs: timeoutOpt('GHOSTBUS_REQUEST_TIMEOUT_MS', SERVER_TIMEOUT_DEFAULTS.requestMs),
|
|
42
|
+
});
|
|
35
43
|
server.listen(port, () => {
|
|
36
44
|
process.stderr.write(`ghostbus http relay v${GHOSTBUS_VERSION} · http://127.0.0.1:${port} · store=${storePath} · auth=${KEY ? 'workspace key required' : 'open (set GHOSTBUS_KEY)'}\n`);
|
|
37
45
|
// Node's listen() with no host binds ALL network interfaces, not just
|