f590a3ac5b
After 2f0edf5 a `pi -p` still waited for one full catch-up feed. lastFeedAt
starts at 0, so the first settled turn feeds, and session_shutdown waits for
that feed. On tor-ms22 this took 45 s (prepare 11 s + mine 31 s) for a 3 s
answer. That feed almost never files the run's own transcript, because a
one-shot run rarely passes the exporter's gate (4 messages, 1000 assistant
chars). What it files is the device's other sessions, and the next interactive
feed re-offers those without duplicates. The source_file is the staged path,
fixed per session UUID. An unchanged file is skipped. For a grown file, its
drawers are purged and then refiled.
Now, when ctx.mode is "print" or "json", neither agent_settled nor
session_shutdown feeds. Without ctx.mode, the bridge falls back to
hasUI === false. A stale ctx keeps the old behaviour. RPC mode is long-lived
and feeds as before. MEMPALACE_FEED_PRINT=1 restores feeding in print and JSON
mode, for a device that only ever runs pi -p.
Measured on tor-ms22 with the workspace copy (-ne -nt --no-session -e):
- pi -p: 2.9 s / 2.8 s
- --mode json: 3.8 s
- -p with MEMPALACE_FEED_PRINT=1: 30.8 s; the probe saw the prepare and race
timers, so the feed ran.
scripts/test-feed-exit.sh now fires agent_settled and then session_shutdown
with a ctx, and gained cases C (print), D (json), E (print +
MEMPALACE_FEED_PRINT=1) and F (rpc). Against 2f0edf5's source, C and D fail
(mines=1, about 1 s wait). All six scripts/test-*.sh pass.
183 lines
10 KiB
Bash
Executable File
183 lines
10 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# test-feed-exit.sh — once the feed's mine has settled, the pi extension in
|
|
# extensions/pi/mempalace.ts must leave NOTHING behind that keeps node alive.
|
|
#
|
|
# WHY THIS EXISTS. `pi -p` (print mode, also `--mode json`) does not call
|
|
# process.exit(): it returns from main() and lets node exit when the event loop
|
|
# is empty. Interactive and RPC mode call process.exit() and never noticed. The
|
|
# feed raced its mine against a bare
|
|
# new Promise((_, reject) => setTimeout(reject, feedMineTimeoutMs))
|
|
# whose timer was never cleared. When the mine won the race (the normal case,
|
|
# 30-60 s), the losing 300 000 ms timer stayed referenced, so every `pi -p` on a
|
|
# devbox printed its answer and then sat idle for the rest of those 5 minutes.
|
|
# Measured on tor-ms22 2026-10-13: the 300 000 ms timer from mempalace.ts was the
|
|
# ONLY active resource from 35 s after the mine returned until exit at ~316 s.
|
|
#
|
|
# Like test-mcp-call-timeout.sh, it runs the SHIPPED text: the whole extension
|
|
# is type-stripped with node's own stripper (the `typebox` import is replaced by
|
|
# a stub) and loaded under a stub pi. A fake feed helper prints MINE_SOURCE, and
|
|
# a local JSON-RPC server (in a SEPARATE process, so it cannot hold the driver's
|
|
# event loop) answers the mine. The driver fires session_shutdown, which awaits
|
|
# the feed, and then simply returns. The harness measures how long node takes to
|
|
# exit after that.
|
|
# A. mine answers in 1 s, deadline 20 s: node must exit right after the
|
|
# handler resolves. Before the fix it lived until the 20 s deadline.
|
|
# B. mine never answers, deadline 2 s: the deadline is still a liveness
|
|
# guard; the handler resolves at about 2 s and node exits.
|
|
# And the print-mode default (2026-10-13): `pi -p` / `--mode json` do not feed.
|
|
# C. ctx.mode "print": neither agent_settled nor session_shutdown mines.
|
|
# D. ctx.mode "json": the same.
|
|
# E. ctx.mode "print" + MEMPALACE_FEED_PRINT=1: mines, as before.
|
|
# F. ctx.mode "rpc" (long-lived): mines, as before.
|
|
# Every case fires agent_settled first and then session_shutdown, the order a
|
|
# real one-turn run sees. In the feeding cases the shutdown joins the tick's
|
|
# in-flight feed, so a run mines exactly once.
|
|
set -euo pipefail
|
|
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
SRC="${1:-$REPO_ROOT/extensions/pi/mempalace.ts}"
|
|
WORK="$(mktemp -d)"
|
|
SERVER_PID=""
|
|
cleanup() { [ -n "$SERVER_PID" ] && kill "$SERVER_PID" 2>/dev/null; rm -rf "$WORK"; }
|
|
trap cleanup EXIT
|
|
[ -r "$SRC" ] || { echo "FAIL: cannot read $SRC" >&2; exit 2; }
|
|
|
|
# ---------------------------------------------------------------- extractor ---
|
|
cat >"$WORK/extract.mjs" <<'EXTRACT'
|
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
import { stripTypeScriptTypes } from "node:module";
|
|
let src = readFileSync(process.argv[2], "utf8");
|
|
const imp = 'import { Type } from "typebox";';
|
|
if (!src.includes(imp)) throw new Error(`expected import line not found: ${imp}`);
|
|
src = src.replace(imp, 'const Type = { Unsafe: (s) => s, Object: () => ({}) };');
|
|
if (!src.includes("feedPalace")) throw new Error("extension does not mention feedPalace");
|
|
writeFileSync(process.argv[3], stripTypeScriptTypes(src, { mode: "strip" }));
|
|
EXTRACT
|
|
node --no-warnings "$WORK/extract.mjs" "$SRC" "$WORK/ext.mjs" || exit 2
|
|
node --check "$WORK/ext.mjs" || { echo "FAIL: stripped extension does not parse" >&2; exit 2; }
|
|
echo "[extract] type-stripped $(basename "$SRC") ($(wc -c <"$WORK/ext.mjs") bytes)"
|
|
|
|
# ------------------------------------------------------------ fake feed bin ---
|
|
cat >"$WORK/fake-feed" <<'FEED'
|
|
#!/bin/sh
|
|
echo "MINE_SOURCE=/nonexistent/fake-stage"
|
|
FEED
|
|
chmod +x "$WORK/fake-feed"
|
|
|
|
# ----------------------------------------------------------- fake palace ------
|
|
# Sessionless JSON-RPC like `mempalace-mcp --transport http`. The mine's delay
|
|
# is set per case through a tiny control endpoint (GET /delay/<ms>), so one
|
|
# server serves both cases.
|
|
cat >"$WORK/server.mjs" <<'SERVER'
|
|
import { createServer } from "node:http";
|
|
import { appendFileSync } from "node:fs";
|
|
let mineDelay = 1000;
|
|
const server = createServer((req, res) => {
|
|
if (req.url.startsWith("/delay/")) { mineDelay = Number(req.url.slice(7)); res.end("ok"); return; }
|
|
let body = "";
|
|
req.on("data", (c) => (body += c));
|
|
req.on("end", () => {
|
|
const msg = JSON.parse(body);
|
|
if (msg.id === undefined) { res.writeHead(202); res.end(); return; }
|
|
const reply = (result) => {
|
|
if (res.destroyed) return;
|
|
res.writeHead(200, { "content-type": "application/json", connection: "close" });
|
|
res.end(JSON.stringify({ jsonrpc: "2.0", id: msg.id, result }));
|
|
};
|
|
if (msg.method === "initialize") return reply({ protocolVersion: "2024-11-05", capabilities: {}, serverInfo: { name: "fake", version: "0" } });
|
|
if (msg.method === "tools/list") return reply({ tools: [{ name: "mempalace_mine", description: "", inputSchema: { type: "object" } }] });
|
|
if (msg.method === "tools/call") {
|
|
appendFileSync(process.env.CALL_LOG, `${msg.params?.name} source=${msg.params?.arguments?.source}\n`);
|
|
return void setTimeout(() => reply({ content: [{ type: "text", text: "mined" }] }), mineDelay);
|
|
}
|
|
reply({});
|
|
});
|
|
});
|
|
server.listen(0, "127.0.0.1", () => console.log(server.address().port));
|
|
SERVER
|
|
CALL_LOG="$WORK/calls.log" node "$WORK/server.mjs" >"$WORK/port" &
|
|
SERVER_PID=$!
|
|
for _ in $(seq 50); do [ -s "$WORK/port" ] && break; sleep 0.1; done
|
|
PORT="$(cat "$WORK/port")"
|
|
[ -n "$PORT" ] || { echo "FAIL: fake palace did not start" >&2; exit 2; }
|
|
|
|
# ------------------------------------------------------------------ driver ----
|
|
# Loads the extension under a stub pi, fires agent_settled and then
|
|
# session_shutdown with the ctx given in DRIVER_CTX, prints when the shutdown
|
|
# handler resolved, then RETURNS. Anything still referenced keeps node alive.
|
|
cat >"$WORK/driver.mjs" <<'DRIVER'
|
|
const t0 = Date.now();
|
|
const ctx = JSON.parse(process.env.DRIVER_CTX || "{}");
|
|
const handlers = {};
|
|
const pi = new Proxy({}, {
|
|
get: (_t, k) => k === "on" ? (ev, fn) => { (handlers[ev] ||= []).push(fn); } : () => {},
|
|
});
|
|
const { default: ext } = await import("./ext.mjs");
|
|
await ext(pi);
|
|
if (!handlers.session_shutdown) { console.log("NO_SHUTDOWN_HANDLER"); process.exitCode = 3; }
|
|
else {
|
|
for (const fn of handlers.agent_settled || []) await fn({ type: "agent_settled" }, ctx);
|
|
for (const fn of handlers.session_shutdown) await fn({ type: "session_shutdown", reason: "quit" }, ctx);
|
|
console.log(`HANDLER_RESOLVED_MS=${Date.now() - t0}`);
|
|
}
|
|
process.on("exit", () => console.log(`EXIT_MS=${Date.now() - t0}`));
|
|
DRIVER
|
|
|
|
failures=0
|
|
check() { if [ "$1" = 0 ]; then echo "ok $2"; else echo "FAIL $2"; failures=$((failures + 1)); fi; }
|
|
|
|
run_case() { # $1 mine delay ms, $2 feed mine timeout ms, $3 ctx JSON, [$4 FEED_PRINT] -> sets HANDLER EXIT RC MINES
|
|
curl -fsS "http://127.0.0.1:$PORT/delay/$1" >/dev/null
|
|
: >"$WORK/calls.log"
|
|
set +e
|
|
out="$(cd "$WORK" && env -u MEMPALACE_REMOTE_TOKEN -u MEMPALACE_PI_DEVICE \
|
|
MEMPALACE_REMOTE_URL="http://127.0.0.1:$PORT/mcp" MEMPALACE_FEED=1 \
|
|
MEMPALACE_FEED_BIN="$WORK/fake-feed" MEMPALACE_FEED_MINE_TIMEOUT_MS="$2" \
|
|
DRIVER_CTX="$3" MEMPALACE_FEED_PRINT="${4:-}" \
|
|
timeout 60 node --no-warnings driver.mjs 2>"$WORK/driver.err")"
|
|
RC=$?
|
|
set -e
|
|
HANDLER="$(printf '%s\n' "$out" | sed -n 's/^HANDLER_RESOLVED_MS=//p')"
|
|
EXIT="$(printf '%s\n' "$out" | sed -n 's/^EXIT_MS=//p')"
|
|
MINES="$(grep -c '^mempalace_mine source=/nonexistent/fake-stage' "$WORK/calls.log" || true)"
|
|
}
|
|
|
|
# A. normal case: the mine wins the race.
|
|
run_case 1000 20000 '{"mode":"tui","hasUI":true}'
|
|
echo "[A] rc=$RC handler=${HANDLER:-?}ms exit=${EXIT:-?}ms mines=$MINES"
|
|
check "$([ "$RC" = 0 ] && echo 0 || echo 1)" "A: driver exits cleanly (rc=$RC)"
|
|
check "$([ "$MINES" = 1 ] && echo 0 || echo 1)" "A: shutdown really mined the staged source once (not a vacuous pass)"
|
|
check "$([ -n "$HANDLER" ] && [ "$HANDLER" -ge 900 ] && [ "$HANDLER" -lt 10000 ] && echo 0 || echo 1)" "A: session_shutdown awaited the 1 s mine (${HANDLER:-?} ms)"
|
|
check "$([ -n "$EXIT" ] && [ -n "$HANDLER" ] && [ $((EXIT - HANDLER)) -lt 3000 ] && echo 0 || echo 1)" \
|
|
"A: node exits within 3 s of the handler resolving ($(( ${EXIT:-0} - ${HANDLER:-0} )) ms; the 20 s mine deadline must not hold it)"
|
|
|
|
# B. wedged palace: the deadline must still end the wait.
|
|
run_case 60000 2000 '{"mode":"tui","hasUI":true}'
|
|
echo "[B] rc=$RC handler=${HANDLER:-?}ms exit=${EXIT:-?}ms mines=$MINES"
|
|
check "$([ "$RC" = 0 ] && echo 0 || echo 1)" "B: driver exits cleanly (rc=$RC)"
|
|
check "$([ -n "$HANDLER" ] && [ "$HANDLER" -ge 1900 ] && [ "$HANDLER" -lt 10000 ] && echo 0 || echo 1)" "B: session_shutdown gives up at the 2 s deadline (${HANDLER:-?} ms)"
|
|
check "$(grep -Eq 'feed \((tick|shutdown)\) failed: .*2000ms' "$WORK/driver.err" && echo 0 || echo 1)" "B: the timeout is reported on stderr"
|
|
check "$([ -n "$EXIT" ] && [ -n "$HANDLER" ] && [ $((EXIT - HANDLER)) -lt 3000 ] && echo 0 || echo 1)" \
|
|
"B: node exits within 3 s of the handler resolving ($(( ${EXIT:-0} - ${HANDLER:-0} )) ms; the 60 s server delay must not hold it)"
|
|
|
|
# C/D. one-shot modes skip the feed: no mine, and node exits at once.
|
|
for m in print json; do
|
|
run_case 1000 20000 "{\"mode\":\"$m\",\"hasUI\":false}"
|
|
echo "[$m] rc=$RC handler=${HANDLER:-?}ms exit=${EXIT:-?}ms mines=$MINES"
|
|
check "$([ "$RC" = 0 ] && [ "$MINES" = 0 ] && echo 0 || echo 1)" "$m mode: no mine at agent_settled or session_shutdown (mines=$MINES)"
|
|
check "$([ -n "$HANDLER" ] && [ "$HANDLER" -lt 900 ] && [ -n "$EXIT" ] && [ $((EXIT - HANDLER)) -lt 3000 ] && echo 0 || echo 1)" \
|
|
"$m mode: shutdown does not wait for a feed (${HANDLER:-?} ms) and node exits promptly"
|
|
done
|
|
|
|
# E. the opt-in restores feeding in print mode.
|
|
run_case 1000 20000 '{"mode":"print","hasUI":false}' 1
|
|
echo "[print+FEED_PRINT=1] rc=$RC handler=${HANDLER:-?}ms exit=${EXIT:-?}ms mines=$MINES"
|
|
check "$([ "$RC" = 0 ] && [ "$MINES" = 1 ] && [ -n "$HANDLER" ] && [ "$HANDLER" -ge 900 ] && echo 0 || echo 1)" \
|
|
"print mode + MEMPALACE_FEED_PRINT=1: mines once and shutdown waits for it (mines=$MINES, ${HANDLER:-?} ms)"
|
|
|
|
# F. RPC mode is long-lived (hasUI true) and keeps feeding.
|
|
run_case 1000 20000 '{"mode":"rpc","hasUI":true}'
|
|
echo "[rpc] rc=$RC handler=${HANDLER:-?}ms exit=${EXIT:-?}ms mines=$MINES"
|
|
check "$([ "$RC" = 0 ] && [ "$MINES" = 1 ] && echo 0 || echo 1)" "rpc mode: still mines (mines=$MINES)"
|
|
|
|
if [ "$failures" = 0 ]; then echo "PASS: all assertions held"; else echo "FAIL: $failures assertion(s) failed"; exit 1; fi
|