pi-sdk-web 0.5.16 → 0.5.17

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/dist/cli.js CHANGED
@@ -9,7 +9,10 @@
9
9
  */
10
10
  import { SettingsManager, createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, getAgentDir, resolveModelScopeWithDiagnostics, } from "@earendil-works/pi-coding-agent";
11
11
  import { existsSync } from "node:fs";
12
+ import { spawn } from "node:child_process";
13
+ import { totalmem } from "node:os";
12
14
  import { fileURLToPath } from "node:url";
15
+ import { getHeapStatistics } from "node:v8";
13
16
  import { dirname, join } from "node:path";
14
17
  import { findSessionByName, listSessions, loadBuiltinExtensions } from "./session.js";
15
18
  import { PiWebServer } from "./server.js";
@@ -43,6 +46,68 @@ if (PI_CLI_ENTRY && process.argv[1] !== PI_CLI_ENTRY) {
43
46
  process.argv[1] = PI_CLI_ENTRY;
44
47
  }
45
48
  const DEFAULT_PORT = 4080;
49
+ // ---------------------------------------------------------------------------
50
+ // Heap headroom for long sessions.
51
+ //
52
+ // A large session plus in-process extensions (magic-context's local embedding
53
+ // model, historians, indexes) can outgrow Node's default old-space limit -
54
+ // ~2.2GB on a 15GB host - and abort the server mid-turn with
55
+ // "FATAL ERROR: Reached heap limit". pi-web therefore restarts itself once
56
+ // with a larger --max-old-space-size, but only for the long-running `r`
57
+ // command and only when the host has memory to spare.
58
+ //
59
+ // Override the target with PI_WEB_MAX_OLD_SPACE_MB=<mb>. An explicit
60
+ // --max-old-space-size in NODE_OPTIONS / argv is always respected untouched,
61
+ // and the restart happens at most once (PI_WEB_HEAP_REEXEC guard).
62
+ // ---------------------------------------------------------------------------
63
+ const DEFAULT_MAX_OLD_SPACE_MB = 4096;
64
+ /** Current V8 old-space limit in MB (what the crash log calls the heap limit). */
65
+ function heapLimitMb() {
66
+ return getHeapStatistics().heap_size_limit / (1024 * 1024);
67
+ }
68
+ function requestedHeapMb() {
69
+ const raw = Number(process.env.PI_WEB_MAX_OLD_SPACE_MB);
70
+ if (Number.isFinite(raw) && raw >= 512 && raw <= 32_768)
71
+ return Math.floor(raw);
72
+ return DEFAULT_MAX_OLD_SPACE_MB;
73
+ }
74
+ /** Did the user (or a wrapper) already choose a heap size themselves? */
75
+ function hasExplicitHeapFlag() {
76
+ const fromEnv = (process.env.NODE_OPTIONS ?? "").split(/\s+/);
77
+ return [...process.execArgv, ...fromEnv].some((arg) => /^--max[-_]old[-_]space[-_]size(=|$)/.test(arg));
78
+ }
79
+ /**
80
+ * Re-exec with a larger heap when needed. Returns true when a child has been
81
+ * started (the caller must stop: this process only waits for it).
82
+ */
83
+ function ensureHeapHeadroom() {
84
+ if (process.env.PI_WEB_HEAP_REEXEC === "1")
85
+ return false; // already restarted
86
+ if (hasExplicitHeapFlag())
87
+ return false; // user decided - leave it alone
88
+ const target = requestedHeapMb();
89
+ if (heapLimitMb() >= target * 0.95)
90
+ return false; // close enough already
91
+ const totalMb = totalmem() / (1024 * 1024);
92
+ if (totalMb < target * 2) {
93
+ console.log(`heap: keeping the default limit (${heapLimitMb().toFixed(0)}MB) - ` +
94
+ `raising it to ${target}MB needs ~${target * 2}MB of RAM, host has ${totalMb.toFixed(0)}MB`);
95
+ return false;
96
+ }
97
+ console.log(`heap: restarting with --max-old-space-size=${target} (current limit ${heapLimitMb().toFixed(0)}MB)`);
98
+ const child = spawn(process.execPath, [`--max-old-space-size=${target}`, fileURLToPath(import.meta.url), ...process.argv.slice(2)], { stdio: "inherit", env: { ...process.env, PI_WEB_HEAP_REEXEC: "1" } });
99
+ // Stay alive without acting on the signal ourselves: the child is in the
100
+ // same process group and runs its own graceful shutdown, and we exit with
101
+ // its status once it is done.
102
+ process.on("SIGINT", () => { });
103
+ process.on("SIGTERM", () => { });
104
+ child.on("exit", (code, signal) => process.exit(signal ? 1 : (code ?? 1)));
105
+ child.on("error", (err) => {
106
+ console.error(`heap: failed to restart with a larger heap: ${err.message}`);
107
+ process.exit(1);
108
+ });
109
+ return true;
110
+ }
46
111
  const DOC = `pi-web - browser Web access for Pi (via Pi SDK)
47
112
 
48
113
  Usage:
@@ -122,6 +187,7 @@ async function cmdResume(name, port) {
122
187
  const server = new PiWebServer(runtime, { port });
123
188
  await server.start();
124
189
  console.log(`server at http://127.0.0.1:${port}/ (session: ${info.name ?? info.id})`);
190
+ console.log(`heap limit: ${heapLimitMb().toFixed(0)}MB`);
125
191
  let shuttingDown = false;
126
192
  const shutdown = async (signal) => {
127
193
  if (shuttingDown)
@@ -178,6 +244,10 @@ async function main() {
178
244
  }
179
245
  if (!name)
180
246
  throw new Error("usage: pi-web r <name> [--port <port>]");
247
+ // Before anything heavy (session open, extensions): make sure this process
248
+ // has enough heap. A restart here means the child takes over the command.
249
+ if (ensureHeapHeadroom())
250
+ return;
181
251
  await cmdResume(name, port ?? DEFAULT_PORT);
182
252
  return;
183
253
  }
package/dist/server.js CHANGED
@@ -64,6 +64,31 @@ const STATS_REFRESH_EVENTS = new Set([
64
64
  "session_info_changed",
65
65
  "thinking_level_changed",
66
66
  ]);
67
+ // ---------------------------------------------------------------------------
68
+ // WebSocket backpressure guard.
69
+ //
70
+ // `ws.send()` queues in process memory without bound when the peer reads
71
+ // slowly - a backgrounded/throttled tab is the common case. A long turn (LLM
72
+ // deltas) or a long `!! … --follow` stream then grows the process until the
73
+ // V8 heap dies (observed: FATAL ERROR: Reached heap limit after ~70 minutes).
74
+ //
75
+ // Two limits, per client:
76
+ // SOFT - above this, *self-healing* delta events are skipped rather than
77
+ // queued. They carry no unique information: message_update /
78
+ // tool_execution_update re-render from the full payload of the next
79
+ // event, and message_end / tool_execution_end / bash_result deliver
80
+ // the complete state.
81
+ // HARD - above this the client is treated as gone and terminated. The
82
+ // browser reconnects (static/app.js) and reloads, fetching fresh
83
+ // session history - so nothing is lost, and memory is bounded.
84
+ // ---------------------------------------------------------------------------
85
+ const WS_BUFFER_SOFT_LIMIT = 8 * 1024 * 1024;
86
+ const WS_BUFFER_HARD_LIMIT = 64 * 1024 * 1024;
87
+ const DROPPABLE_DELTA_EVENTS = new Set(["message_update", "tool_execution_update"]);
88
+ /** RSS watermarks (MB) at which to log one diagnostic line: growth curve plus
89
+ * what each client has queued, so a future OOM report says where it went. */
90
+ const MEMORY_LOG_THRESHOLDS_MB = [512, 1024, 1536, 2048];
91
+ const MEMORY_LOG_INTERVAL_MS = 30_000;
67
92
  const MIME = {
68
93
  ".html": "text/html; charset=utf-8",
69
94
  ".css": "text/css; charset=utf-8",
@@ -83,6 +108,11 @@ export class PiWebServer {
83
108
  wsServer = null;
84
109
  clients = new Set();
85
110
  unsubscribe = null;
111
+ /** Clients currently over the soft buffer limit (delta skipping active). */
112
+ backpressured = new WeakSet();
113
+ /** Memory watermarks already logged (diagnostics), and throttle clock. */
114
+ memoryLogged = new Set();
115
+ lastMemoryCheck = 0;
86
116
  /** Current session (may change on /resume session switching). */
87
117
  get session() {
88
118
  return this.runtime.session;
@@ -233,9 +263,33 @@ export class PiWebServer {
233
263
  catch {
234
264
  return;
235
265
  }
236
- for (const client of this.clients) {
266
+ const type = obj.type;
267
+ const droppable = typeof type === "string" && DROPPABLE_DELTA_EVENTS.has(type);
268
+ for (const client of [...this.clients]) {
237
269
  if (client.readyState !== WebSocket.OPEN)
238
270
  continue;
271
+ const buffered = client.bufferedAmount;
272
+ if (buffered > WS_BUFFER_HARD_LIMIT) {
273
+ // Far beyond reading - treat as gone. The browser reconnects and
274
+ // reloads, so no state is lost, and memory stays bounded.
275
+ console.warn(`pi-web: client not reading (${(buffered / 1048576).toFixed(0)}MB queued) - closing it; ` +
276
+ "the browser will reconnect and reload history");
277
+ this.backpressured.delete(client);
278
+ client.terminate();
279
+ continue;
280
+ }
281
+ if (droppable && buffered > WS_BUFFER_SOFT_LIMIT) {
282
+ if (!this.backpressured.has(client)) {
283
+ this.backpressured.add(client);
284
+ console.warn(`pi-web: client is behind (${(buffered / 1048576).toFixed(1)}MB queued) - ` +
285
+ "skipping stream deltas until it catches up");
286
+ }
287
+ continue;
288
+ }
289
+ if (this.backpressured.has(client) && buffered < WS_BUFFER_SOFT_LIMIT / 2) {
290
+ this.backpressured.delete(client);
291
+ console.warn("pi-web: client caught up - stream deltas resumed");
292
+ }
239
293
  try {
240
294
  client.send(message);
241
295
  }
@@ -246,6 +300,28 @@ export class PiWebServer {
246
300
  this.uiContext.setBrowserAttached(false);
247
301
  }
248
302
  }
303
+ this.maybeLogMemory();
304
+ }
305
+ /**
306
+ * One diagnostic line per RSS watermark (plus what each client has queued),
307
+ * so a future out-of-memory report shows where the growth went. Throttled:
308
+ * `broadcast` runs per streaming delta and memoryUsage() is not free.
309
+ */
310
+ maybeLogMemory() {
311
+ const now = Date.now();
312
+ if (now - this.lastMemoryCheck < MEMORY_LOG_INTERVAL_MS)
313
+ return;
314
+ this.lastMemoryCheck = now;
315
+ const { rss, heapUsed, heapTotal } = process.memoryUsage();
316
+ const rssMb = rss / 1048576;
317
+ for (const threshold of MEMORY_LOG_THRESHOLDS_MB) {
318
+ if (rssMb < threshold || this.memoryLogged.has(threshold))
319
+ continue;
320
+ this.memoryLogged.add(threshold);
321
+ const queued = [...this.clients].map((c) => `${(c.bufferedAmount / 1048576).toFixed(1)}MB`).join(", ") || "no clients";
322
+ console.warn(`pi-web: memory ${rssMb.toFixed(0)}MB (heap ${(heapUsed / 1048576).toFixed(0)}/` +
323
+ `${(heapTotal / 1048576).toFixed(0)}MB), queued per client: ${queued}`);
324
+ }
249
325
  }
250
326
  broadcastStats() {
251
327
  try {
@@ -348,9 +424,17 @@ export class PiWebServer {
348
424
  }
349
425
  }
350
426
  sendJson(ws, obj) {
351
- if (ws.readyState === WebSocket.OPEN) {
352
- ws.send(JSON.stringify(obj));
427
+ if (ws.readyState !== WebSocket.OPEN)
428
+ return;
429
+ // Same hard limit as broadcast(): the initial state/history snapshot is
430
+ // the largest single payload, so a client that never reads it must not be
431
+ // allowed to queue forever.
432
+ if (ws.bufferedAmount > WS_BUFFER_HARD_LIMIT) {
433
+ console.warn("pi-web: client not reading its initial snapshot - closing it");
434
+ ws.terminate();
435
+ return;
353
436
  }
437
+ ws.send(JSON.stringify(obj));
354
438
  }
355
439
  buildState() {
356
440
  const state = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-sdk-web",
3
- "version": "0.5.16",
3
+ "version": "0.5.17",
4
4
  "description": "Browser Web access for Pi (AI coding agent) via the Pi SDK - standalone module, zero modification to Pi itself",
5
5
  "type": "module",
6
6
  "bin": {