wicked-brain 0.15.3 → 0.15.4
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/package.json +1 -1
- package/server/bin/wicked-brain-call.mjs +126 -13
- package/server/package.json +1 -1
package/package.json
CHANGED
|
@@ -161,6 +161,28 @@ async function healthInfo(port, { timeoutMs = 800 } = {}) {
|
|
|
161
161
|
}
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
+
// Raised by ensureServer when a server answers the persisted port but it
|
|
165
|
+
// belongs to a DIFFERENT brain than the one we resolved. The data path
|
|
166
|
+
// (search/index/remove/forget/query/...) routes through ensureServer, so this
|
|
167
|
+
// guard is the single choke point that stops a destructive op from silently
|
|
168
|
+
// hitting the wrong brain on a shared/recycled port. Fail closed — never
|
|
169
|
+
// operate on a mismatched brain.
|
|
170
|
+
class PortConflictError extends Error {
|
|
171
|
+
constructor({ port, expectedBrainId, actualBrainId }) {
|
|
172
|
+
super(
|
|
173
|
+
`port_conflict: port ${port} is held by a different brain ` +
|
|
174
|
+
`(expected brain_id "${expectedBrainId}", got "${actualBrainId ?? "unknown"}"). ` +
|
|
175
|
+
`Refusing the operation so it can't hit the wrong brain. ` +
|
|
176
|
+
`Stop the other server, free the port, or pass the correct --port for this brain.`,
|
|
177
|
+
);
|
|
178
|
+
this.name = "PortConflictError";
|
|
179
|
+
this.code = "port_conflict";
|
|
180
|
+
this.port = port;
|
|
181
|
+
this.expectedBrainId = expectedBrainId;
|
|
182
|
+
this.actualBrainId = actualBrainId ?? null;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
164
186
|
// ---------- spawn lock ----------
|
|
165
187
|
// Cross-platform exclusive-create lock via { flag: "wx" }. Stale entries
|
|
166
188
|
// (older than STALE_LOCK_MS) are reaped on contention so a crashed CLI
|
|
@@ -233,12 +255,42 @@ function openServerLog(brainPath) {
|
|
|
233
255
|
}
|
|
234
256
|
}
|
|
235
257
|
|
|
258
|
+
// Reconcile the responding server's brain_id against the brain we resolved.
|
|
259
|
+
// Returns true when the port is ours (or when expectedBrainId is unknown and
|
|
260
|
+
// we choose not to gate). Throws PortConflictError when a DIFFERENT brain
|
|
261
|
+
// answers — the fail-closed path that protects the data plane. One health
|
|
262
|
+
// round-trip per resolution; cheap enough that we don't cache across calls
|
|
263
|
+
// (each CLI invocation resolves the server exactly once anyway).
|
|
264
|
+
function reconcileHealth(health, { port, expectedBrainId }) {
|
|
265
|
+
// No expected id to compare against (no brain.json id, no basename) — can't
|
|
266
|
+
// meaningfully gate, so don't. In practice readExpectedBrainId always yields
|
|
267
|
+
// at least the basename, so this is a defensive fallback only.
|
|
268
|
+
if (!expectedBrainId) return true;
|
|
269
|
+
if (health.brain_id === expectedBrainId) return true;
|
|
270
|
+
throw new PortConflictError({
|
|
271
|
+
port,
|
|
272
|
+
expectedBrainId,
|
|
273
|
+
actualBrainId: health.brain_id,
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
236
277
|
async function ensureServer(brainPath, opts) {
|
|
237
278
|
const { explicitPort, sourceOverride, noSpawn, log, spawnTimeoutMs = 10_000 } = opts;
|
|
238
279
|
const meta = readMetaConfig(brainPath);
|
|
239
280
|
const port = explicitPort || meta.server_port || 4242;
|
|
240
|
-
|
|
241
|
-
|
|
281
|
+
// The brain we EXPECT on this port — same source --status/--stop reconcile
|
|
282
|
+
// against (brain.json `id`, falling back to the per-project basename).
|
|
283
|
+
const expectedBrainId = opts.expectedBrainId ?? readExpectedBrainId(brainPath);
|
|
284
|
+
|
|
285
|
+
// Warm path: a server already answers the port. Confirm it's OUR brain before
|
|
286
|
+
// handing the port to the data plane. A foreign brain can occupy this port
|
|
287
|
+
// (stale config, manual restart, EADDRINUSE probe-up), and routing by port
|
|
288
|
+
// alone would let a destructive op (remove/forget) silently hit it.
|
|
289
|
+
const warmHealth = await healthInfo(port);
|
|
290
|
+
if (warmHealth) {
|
|
291
|
+
reconcileHealth(warmHealth, { port, expectedBrainId });
|
|
292
|
+
return port;
|
|
293
|
+
}
|
|
242
294
|
if (noSpawn) {
|
|
243
295
|
throw new Error(`server not reachable on port ${port} and --no-spawn was set`);
|
|
244
296
|
}
|
|
@@ -248,14 +300,26 @@ async function ensureServer(brainPath, opts) {
|
|
|
248
300
|
|
|
249
301
|
if (!tryLock(lockPath)) {
|
|
250
302
|
log(`another process is starting the server; waiting...`);
|
|
251
|
-
|
|
303
|
+
const concurrentHealth = await waitForHealthInfo(port, spawnTimeoutMs);
|
|
304
|
+
if (concurrentHealth) {
|
|
305
|
+
// The peer that held the lock brought a server up — confirm it's OUR
|
|
306
|
+
// brain before we route the data plane at it.
|
|
307
|
+
reconcileHealth(concurrentHealth, { port, expectedBrainId });
|
|
308
|
+
return port;
|
|
309
|
+
}
|
|
252
310
|
throw new Error(`concurrent spawn timed out on port ${port}`);
|
|
253
311
|
}
|
|
254
312
|
|
|
255
313
|
try {
|
|
256
314
|
// Re-check after acquiring the lock — another process might have started
|
|
257
|
-
// and finished while we were contending.
|
|
258
|
-
|
|
315
|
+
// and finished while we were contending. Reconcile brain_id here too: the
|
|
316
|
+
// server that came up while we waited could be a different brain probing up
|
|
317
|
+
// onto this port.
|
|
318
|
+
const relockHealth = await healthInfo(port);
|
|
319
|
+
if (relockHealth) {
|
|
320
|
+
reconcileHealth(relockHealth, { port, expectedBrainId });
|
|
321
|
+
return port;
|
|
322
|
+
}
|
|
259
323
|
|
|
260
324
|
const pidPath = join(brainPath, "_meta", "server.pid");
|
|
261
325
|
if (existsSync(pidPath)) {
|
|
@@ -299,7 +363,14 @@ async function ensureServer(brainPath, opts) {
|
|
|
299
363
|
}
|
|
300
364
|
if (logFd !== null) log(`server logs -> ${logPath}`);
|
|
301
365
|
|
|
302
|
-
|
|
366
|
+
const ready = await waitForHealthInfo(port, spawnTimeoutMs);
|
|
367
|
+
if (ready) {
|
|
368
|
+
// We spawned with --brain brainPath, so the server that comes up should
|
|
369
|
+
// be ours. Reconcile anyway: a foreign brain could have won the bind in
|
|
370
|
+
// the race window before our child listened. Fail closed if so.
|
|
371
|
+
reconcileHealth(ready, { port, expectedBrainId });
|
|
372
|
+
return port;
|
|
373
|
+
}
|
|
303
374
|
throw new Error(
|
|
304
375
|
`server did not become ready within ${spawnTimeoutMs}ms on port ${port}` +
|
|
305
376
|
(logFd !== null ? ` — see ${logPath} for the cause` : ""),
|
|
@@ -318,6 +389,18 @@ async function waitForHealth(port, timeoutMs) {
|
|
|
318
389
|
return false;
|
|
319
390
|
}
|
|
320
391
|
|
|
392
|
+
// Like waitForHealth but returns the health body (carrying brain_id) once the
|
|
393
|
+
// port answers, so the spawn path can confirm WHICH brain came up.
|
|
394
|
+
async function waitForHealthInfo(port, timeoutMs) {
|
|
395
|
+
const deadline = Date.now() + timeoutMs;
|
|
396
|
+
while (Date.now() < deadline) {
|
|
397
|
+
const h = await healthInfo(port, { timeoutMs: 500 });
|
|
398
|
+
if (h) return h;
|
|
399
|
+
await sleep(150);
|
|
400
|
+
}
|
|
401
|
+
return null;
|
|
402
|
+
}
|
|
403
|
+
|
|
321
404
|
function sleep(ms) {
|
|
322
405
|
return new Promise(r => setTimeout(r, ms));
|
|
323
406
|
}
|
|
@@ -644,13 +727,43 @@ Examples:
|
|
|
644
727
|
}
|
|
645
728
|
}
|
|
646
729
|
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
730
|
+
let port;
|
|
731
|
+
try {
|
|
732
|
+
port = await ensureServer(brainPath, {
|
|
733
|
+
explicitPort: args.flags.port,
|
|
734
|
+
sourceOverride: args.flags.source,
|
|
735
|
+
noSpawn: args.flags.noSpawn,
|
|
736
|
+
spawnTimeoutMs: args.flags.spawnTimeoutMs,
|
|
737
|
+
log,
|
|
738
|
+
});
|
|
739
|
+
} catch (err) {
|
|
740
|
+
if (err instanceof PortConflictError) {
|
|
741
|
+
// Fail closed: the persisted port is held by a different brain. Refuse the
|
|
742
|
+
// op (read OR mutate) so a destructive call (remove/forget/index) can't
|
|
743
|
+
// silently hit the wrong brain. Emit a structured payload on stdout that
|
|
744
|
+
// mirrors --stop/--status, plus a non-zero exit for scripted callers.
|
|
745
|
+
const payload = {
|
|
746
|
+
error: err.message,
|
|
747
|
+
action,
|
|
748
|
+
refused: true,
|
|
749
|
+
reason: "port_conflict",
|
|
750
|
+
port: err.port,
|
|
751
|
+
brain_id: err.expectedBrainId,
|
|
752
|
+
actual_brain_id: err.actualBrainId,
|
|
753
|
+
};
|
|
754
|
+
// Leave a refusal breadcrumb so the audit trail shows the op was blocked.
|
|
755
|
+
if (auditEnabled(args.flags.noAudit)) {
|
|
756
|
+
const a = writeAuditOpen(brainPath, action, params, err.port);
|
|
757
|
+
writeAuditClose(a, { exitCode: 1, durationMs: 0, response: payload, error: err.message });
|
|
758
|
+
}
|
|
759
|
+
process.stdout.write(
|
|
760
|
+
(args.flags.pretty ? JSON.stringify(payload, null, 2) : JSON.stringify(payload)) + "\n",
|
|
761
|
+
);
|
|
762
|
+
process.exitCode = 1;
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
die(err.message);
|
|
766
|
+
}
|
|
654
767
|
|
|
655
768
|
// Open audit BEFORE the call so a crash mid-flight still leaves a partial
|
|
656
769
|
// record. Audit is best-effort — write failures never block the request.
|