@code-yeongyu/senpi 2026.9.17-2 → 2026.9.17-3
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 +35 -0
- package/dist/bundle/chunks/{app-server-command-XSPV6BRF.js → app-server-command-HDTNYVSW.js} +1 -1
- package/dist/bundle/chunks/{chunk-PLUQUK3T.js → chunk-CUBEOOBJ.js} +15 -15
- package/dist/bundle/chunks/{chunk-ESZHT7IE.js → chunk-IMTGM3RJ.js} +1 -1
- package/dist/bundle/chunks/chunk-OQGI752B.js +2 -0
- package/dist/bundle/chunks/{chunk-Z56RXBL6.js → chunk-QDR3GME3.js} +2 -2
- package/dist/bundle/chunks/{chunk-ZPO5FWHM.js → chunk-QILSJ5PQ.js} +2 -2
- package/dist/bundle/chunks/chunk-V6IBT2QS.js +8 -0
- package/dist/bundle/chunks/{chunk-IGKWKAMN.js → chunk-XHTF6VKZ.js} +4 -3
- package/dist/bundle/chunks/{cli-main-L7WQP5V6.js → cli-main-IPY5CCRP.js} +1 -1
- package/dist/bundle/chunks/{help-fast-path-O6MBSNMK.js → help-fast-path-LFGHCIWO.js} +1 -1
- package/dist/bundle/chunks/{host-lifecycle-AI7NH6ZO.js → host-lifecycle-YPVGQSGS.js} +1 -1
- package/dist/bundle/chunks/{interactive-host-runtime-DKJKH5DU.js → interactive-host-runtime-KZLF3AZD.js} +1 -1
- package/dist/bundle/chunks/{interactive-mode-FUW5LNKL.js → interactive-mode-HFE7ROP2.js} +1 -1
- package/dist/bundle/chunks/multi-session-host-VMVADJFM.js +20 -0
- package/dist/bundle/chunks/{package-manager-cli-J26DB6GM.js → package-manager-cli-AX4WQCCG.js} +1 -1
- package/dist/bundle/chunks/rotation-stream-FSZVBG2T.js +2 -0
- package/dist/bundle/chunks/{rpc-mode-A3U63XX7.js → rpc-mode-PDYLQ5BZ.js} +1 -1
- package/dist/bundle/chunks/session-worker.js +71 -68
- package/dist/bundle/cli.js +1 -1
- package/dist/bundle/index.js +1 -1
- package/dist/bundle/rpc-entry.js +1 -1
- package/dist/cli/args.d.ts +19 -1
- package/dist/cli/args.d.ts.map +1 -1
- package/dist/cli/args.js +27 -1
- package/dist/cli/args.js.map +1 -1
- package/dist/core/agent-session-runtime.d.ts +14 -1
- package/dist/core/agent-session-runtime.d.ts.map +1 -1
- package/dist/core/agent-session-runtime.js.map +1 -1
- package/dist/core/auth-storage.js +2 -2
- package/dist/core/auth-storage.js.map +1 -1
- package/dist/core/credential-pool/rotation-stream.d.ts.map +1 -1
- package/dist/core/credential-pool/rotation-stream.js +10 -5
- package/dist/core/credential-pool/rotation-stream.js.map +1 -1
- package/dist/core/extensions/builtin/mcp/index.d.ts.map +1 -1
- package/dist/core/extensions/builtin/mcp/index.js +11 -0
- package/dist/core/extensions/builtin/mcp/index.js.map +1 -1
- package/dist/core/extensions/builtin/mcp/service.d.ts +9 -0
- package/dist/core/extensions/builtin/mcp/service.d.ts.map +1 -1
- package/dist/core/extensions/builtin/mcp/service.js +14 -1
- package/dist/core/extensions/builtin/mcp/service.js.map +1 -1
- package/dist/core/extensions/builtin/mcp/startup-race.d.ts +20 -0
- package/dist/core/extensions/builtin/mcp/startup-race.d.ts.map +1 -1
- package/dist/core/extensions/builtin/mcp/startup-race.js +38 -1
- package/dist/core/extensions/builtin/mcp/startup-race.js.map +1 -1
- package/dist/core/extensions/index.d.ts +2 -2
- package/dist/core/extensions/index.d.ts.map +1 -1
- package/dist/core/extensions/index.js +1 -1
- package/dist/core/extensions/index.js.map +1 -1
- package/dist/core/extensions/loader.d.ts +9 -4
- package/dist/core/extensions/loader.d.ts.map +1 -1
- package/dist/core/extensions/loader.js +20 -9
- package/dist/core/extensions/loader.js.map +1 -1
- package/dist/core/extensions/types.d.ts +37 -0
- package/dist/core/extensions/types.d.ts.map +1 -1
- package/dist/core/extensions/types.js +8 -0
- package/dist/core/extensions/types.js.map +1 -1
- package/dist/core/footer-data-provider.d.ts +2 -1
- package/dist/core/footer-data-provider.d.ts.map +1 -1
- package/dist/core/footer-data-provider.js +21 -35
- package/dist/core/footer-data-provider.js.map +1 -1
- package/dist/core/model-registry.d.ts.map +1 -1
- package/dist/core/model-registry.js +3 -2
- package/dist/core/model-registry.js.map +1 -1
- package/dist/core/model-runtime.d.ts +4 -2
- package/dist/core/model-runtime.d.ts.map +1 -1
- package/dist/core/model-runtime.js +9 -5
- package/dist/core/model-runtime.js.map +1 -1
- package/dist/core/provider-api-key-auth.js +3 -3
- package/dist/core/provider-api-key-auth.js.map +1 -1
- package/dist/core/provider-composer.d.ts +9 -3
- package/dist/core/provider-composer.d.ts.map +1 -1
- package/dist/core/provider-composer.js +7 -4
- package/dist/core/provider-composer.js.map +1 -1
- package/dist/core/resolve-config-command.d.ts +10 -0
- package/dist/core/resolve-config-command.d.ts.map +1 -0
- package/dist/core/resolve-config-command.js +106 -0
- package/dist/core/resolve-config-command.js.map +1 -0
- package/dist/core/resolve-config-value.d.ts +4 -8
- package/dist/core/resolve-config-value.d.ts.map +1 -1
- package/dist/core/resolve-config-value.js +16 -105
- package/dist/core/resolve-config-value.js.map +1 -1
- package/dist/core/resource-loader.d.ts +7 -1
- package/dist/core/resource-loader.d.ts.map +1 -1
- package/dist/core/resource-loader.js +13 -9
- package/dist/core/resource-loader.js.map +1 -1
- package/dist/main.d.ts +5 -3
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +25 -11
- package/dist/main.js.map +1 -1
- package/dist/modes/rpc/child-reaper-syscalls.d.ts +18 -0
- package/dist/modes/rpc/child-reaper-syscalls.d.ts.map +1 -0
- package/dist/modes/rpc/child-reaper-syscalls.js +136 -0
- package/dist/modes/rpc/child-reaper-syscalls.js.map +1 -0
- package/dist/modes/rpc/child-reaper.d.ts +56 -0
- package/dist/modes/rpc/child-reaper.d.ts.map +1 -0
- package/dist/modes/rpc/child-reaper.js +140 -0
- package/dist/modes/rpc/child-reaper.js.map +1 -0
- package/dist/modes/rpc/custom-capability.d.ts +16 -0
- package/dist/modes/rpc/custom-capability.d.ts.map +1 -1
- package/dist/modes/rpc/custom-capability.js +16 -0
- package/dist/modes/rpc/custom-capability.js.map +1 -1
- package/dist/modes/rpc/host-memory-sampler.d.ts +48 -0
- package/dist/modes/rpc/host-memory-sampler.d.ts.map +1 -0
- package/dist/modes/rpc/host-memory-sampler.js +72 -0
- package/dist/modes/rpc/host-memory-sampler.js.map +1 -0
- package/dist/modes/rpc/loop-lag-watchdog.d.ts +51 -0
- package/dist/modes/rpc/loop-lag-watchdog.d.ts.map +1 -0
- package/dist/modes/rpc/loop-lag-watchdog.js +78 -0
- package/dist/modes/rpc/loop-lag-watchdog.js.map +1 -0
- package/dist/modes/rpc/multi-session-host.d.ts.map +1 -1
- package/dist/modes/rpc/multi-session-host.js +34 -0
- package/dist/modes/rpc/multi-session-host.js.map +1 -1
- package/dist/modes/rpc/rpc-client.d.ts +4 -2
- package/dist/modes/rpc/rpc-client.d.ts.map +1 -1
- package/dist/modes/rpc/rpc-client.js +2 -1
- package/dist/modes/rpc/rpc-client.js.map +1 -1
- package/dist/modes/rpc/rpc-input-validation.d.ts +28 -0
- package/dist/modes/rpc/rpc-input-validation.d.ts.map +1 -1
- package/dist/modes/rpc/rpc-input-validation.js +63 -0
- package/dist/modes/rpc/rpc-input-validation.js.map +1 -1
- package/dist/modes/rpc/rpc-mode.d.ts +18 -5
- package/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
- package/dist/modes/rpc/rpc-mode.js +18 -5
- package/dist/modes/rpc/rpc-mode.js.map +1 -1
- package/dist/modes/rpc/rpc-types.d.ts +79 -2
- package/dist/modes/rpc/rpc-types.d.ts.map +1 -1
- package/dist/modes/rpc/rpc-types.js +6 -0
- package/dist/modes/rpc/rpc-types.js.map +1 -1
- package/dist/modes/rpc/session-attribution.d.ts +57 -0
- package/dist/modes/rpc/session-attribution.d.ts.map +1 -0
- package/dist/modes/rpc/session-attribution.js +132 -0
- package/dist/modes/rpc/session-attribution.js.map +1 -0
- package/dist/modes/rpc/session-binding.d.ts.map +1 -1
- package/dist/modes/rpc/session-binding.js +19 -8
- package/dist/modes/rpc/session-binding.js.map +1 -1
- package/dist/modes/rpc/session-command-router.d.ts +26 -3
- package/dist/modes/rpc/session-command-router.d.ts.map +1 -1
- package/dist/modes/rpc/session-command-router.js +95 -19
- package/dist/modes/rpc/session-command-router.js.map +1 -1
- package/dist/modes/rpc/session-event-fanout.d.ts +2 -0
- package/dist/modes/rpc/session-event-fanout.d.ts.map +1 -1
- package/dist/modes/rpc/session-event-fanout.js +6 -0
- package/dist/modes/rpc/session-event-fanout.js.map +1 -1
- package/dist/modes/rpc/session-event-writer.d.ts +29 -0
- package/dist/modes/rpc/session-event-writer.d.ts.map +1 -1
- package/dist/modes/rpc/session-event-writer.js +57 -0
- package/dist/modes/rpc/session-event-writer.js.map +1 -1
- package/dist/modes/rpc/session-registry.d.ts +42 -9
- package/dist/modes/rpc/session-registry.d.ts.map +1 -1
- package/dist/modes/rpc/session-registry.js +49 -3
- package/dist/modes/rpc/session-registry.js.map +1 -1
- package/dist/modes/rpc/worker-session-registry.d.ts +2 -10
- package/dist/modes/rpc/worker-session-registry.d.ts.map +1 -1
- package/dist/modes/rpc/worker-session-registry.js +6 -2
- package/dist/modes/rpc/worker-session-registry.js.map +1 -1
- package/docs/rpc.md +126 -20
- package/node_modules/@code-yeongyu/senpi-codemode/CHANGELOG.md +12 -0
- package/node_modules/@code-yeongyu/senpi-codemode/package.json +4 -4
- package/node_modules/@earendil-works/pi-agent-core/assets/tree-sitter/javascript.wasm +0 -0
- package/node_modules/@earendil-works/pi-agent-core/assets/tree-sitter/provenance.json +22 -0
- package/node_modules/@earendil-works/pi-agent-core/assets/tree-sitter/web-tree-sitter.wasm +0 -0
- package/node_modules/@earendil-works/pi-agent-core/package.json +3 -3
- package/node_modules/@earendil-works/pi-ai/dist/providers/data/.manifest.json +1 -1
- package/node_modules/@earendil-works/pi-ai/dist/providers/data/cloudflare-ai-gateway.json +1 -1
- package/node_modules/@earendil-works/pi-ai/dist/providers/data/openrouter.json +1 -1
- package/node_modules/@earendil-works/pi-ai/dist/providers/data/zai-coding-cn.json +1 -1
- package/node_modules/@earendil-works/pi-ai/package.json +2 -2
- package/node_modules/@earendil-works/pi-pty/package.json +1 -1
- package/node_modules/@earendil-works/pi-telemetry/package.json +1 -1
- package/node_modules/@earendil-works/pi-tui/package.json +1 -1
- package/package.json +7 -7
- package/dist/bundle/chunks/chunk-3Q5PD4KW.js +0 -8
- package/dist/bundle/chunks/chunk-YLYLKL7X.js +0 -2
- package/dist/bundle/chunks/multi-session-host-SMNZXFPT.js +0 -18
- package/dist/bundle/chunks/rotation-stream-SCVFIQ2O.js +0 -2
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process-table access for the host child reaper.
|
|
3
|
+
*
|
|
4
|
+
* Three operations, each with exactly one job:
|
|
5
|
+
* - `listDirectChildren` enumerates the DIRECT children of this process
|
|
6
|
+
* (darwin: libproc `proc_listchildpids`, which lists zombies too; linux:
|
|
7
|
+
* a `/proc/<pid>/stat` ppid scan). Never a `ps` spawn - a reaper that
|
|
8
|
+
* spawns children to find children is the bug it is meant to fix (#1507).
|
|
9
|
+
* - `isWaitable` is the ORACLE: `waitid(P_PID, pid, WEXITED|WNOHANG|WNOWAIT)`
|
|
10
|
+
* peeks at the exit status WITHOUT consuming it, so an observation never
|
|
11
|
+
* steals a child from the thread that owns it. `proc_pidinfo` is NOT used
|
|
12
|
+
* for this: it reports nothing trustworthy about a zombie.
|
|
13
|
+
* - `reapExited` consumes exactly one pid with `waitpid(pid, WNOHANG)`.
|
|
14
|
+
* `waitpid(-1, ...)` is never called: it would steal an arbitrary child
|
|
15
|
+
* from whichever thread was waiting for it.
|
|
16
|
+
*
|
|
17
|
+
* RUNTIME BOUNDARY (the repository's one sanctioned reason for a dynamic
|
|
18
|
+
* import, same shape as `ownership-safe-lock.ts`'s `bun:sqlite` loader): the
|
|
19
|
+
* bindings need `bun:ffi`, a Bun-only builtin. A TOP-LEVEL import of it makes
|
|
20
|
+
* this module - and therefore every host that reaches it - unloadable on Node
|
|
21
|
+
* (`ERR_UNSUPPORTED_ESM_URL_SCHEME: Received protocol 'bun:'`, verified). The
|
|
22
|
+
* specifier is therefore imported behind the runtime gate in
|
|
23
|
+
* `loadChildReaperSyscalls`, which resolves to `undefined` on Node so the
|
|
24
|
+
* reaper turns itself off with one warning. Everything else here is top-level.
|
|
25
|
+
*/
|
|
26
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
27
|
+
/** waitid/waitpid option bits and idtype, which differ between the two platforms. */
|
|
28
|
+
const P_PID = 1;
|
|
29
|
+
const WNOHANG = 0x01;
|
|
30
|
+
const WEXITED = 0x04;
|
|
31
|
+
const WNOWAIT = { darwin: 0x20, linux: 0x0100_0000 };
|
|
32
|
+
/** Byte offset of `si_pid` inside `siginfo_t` (darwin packs it right after si_code). */
|
|
33
|
+
const SI_PID_OFFSET = { darwin: 12, linux: 16 };
|
|
34
|
+
/** A host with more live children than this is pathological; the scan stays bounded. */
|
|
35
|
+
const MAX_TRACKED_CHILDREN = 4096;
|
|
36
|
+
function supportedPlatform(platform) {
|
|
37
|
+
return platform === "darwin" || platform === "linux" ? platform : undefined;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Loads the platform bindings, or `undefined` when this runtime cannot reap
|
|
41
|
+
* (Node, Windows, or a libc without the symbols). The platform and the runtime
|
|
42
|
+
* are both decided BEFORE the `bun:ffi` specifier is reached, so a Node or
|
|
43
|
+
* win32 host never asks its loader for a module it cannot resolve.
|
|
44
|
+
*/
|
|
45
|
+
export async function loadChildReaperSyscalls(platform = process.platform) {
|
|
46
|
+
const supported = supportedPlatform(platform);
|
|
47
|
+
if (supported === undefined)
|
|
48
|
+
return undefined;
|
|
49
|
+
if (typeof globalThis.Bun === "undefined")
|
|
50
|
+
return undefined;
|
|
51
|
+
const { dlopen, FFIType, ptr } = await import("bun:ffi");
|
|
52
|
+
const library = supported === "darwin" ? "libSystem.B.dylib" : "libc.so.6";
|
|
53
|
+
const wait = dlopen(library, {
|
|
54
|
+
waitid: { args: [FFIType.i32, FFIType.u32, FFIType.ptr, FFIType.i32], returns: FFIType.i32 },
|
|
55
|
+
waitpid: { args: [FFIType.i32, FFIType.ptr, FFIType.i32], returns: FFIType.i32 },
|
|
56
|
+
});
|
|
57
|
+
const info = new Uint8Array(512);
|
|
58
|
+
const infoView = new DataView(info.buffer);
|
|
59
|
+
const status = new Int32Array(1);
|
|
60
|
+
const peekOptions = WEXITED | WNOHANG | WNOWAIT[supported];
|
|
61
|
+
const siPidOffset = SI_PID_OFFSET[supported];
|
|
62
|
+
return {
|
|
63
|
+
listDirectChildren: supported === "darwin" ? darwinChildren(dlopen, FFIType, ptr) : linuxChildren,
|
|
64
|
+
isWaitable(pid) {
|
|
65
|
+
// Zeroed before every call: with WNOHANG the kernel leaves si_pid at 0
|
|
66
|
+
// when the child has not exited, and that is the whole signal.
|
|
67
|
+
info.fill(0);
|
|
68
|
+
const result = wait.symbols.waitid(P_PID, pid, ptr(info), peekOptions);
|
|
69
|
+
return result === 0 && infoView.getInt32(siPidOffset, true) === pid;
|
|
70
|
+
},
|
|
71
|
+
reapExited(pid) {
|
|
72
|
+
if (pid <= 0)
|
|
73
|
+
throw new Error(`child reaper refuses to wait on pid ${pid}`);
|
|
74
|
+
return wait.symbols.waitpid(pid, ptr(status), WNOHANG) === pid;
|
|
75
|
+
},
|
|
76
|
+
describe: supported === "darwin" ? darwinName(dlopen, FFIType, ptr) : linuxName,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
function darwinChildren(dlopen, FFIType, ptr) {
|
|
80
|
+
const libproc = dlopen("libSystem.B.dylib", {
|
|
81
|
+
proc_listchildpids: { args: [FFIType.i32, FFIType.ptr, FFIType.i32], returns: FFIType.i32 },
|
|
82
|
+
});
|
|
83
|
+
const pids = new Int32Array(MAX_TRACKED_CHILDREN);
|
|
84
|
+
return () => {
|
|
85
|
+
const found = libproc.symbols.proc_listchildpids(process.pid, ptr(pids), pids.byteLength);
|
|
86
|
+
return found <= 0 ? [] : Array.from(pids.subarray(0, Math.min(found, pids.length)));
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
function darwinName(dlopen, FFIType, ptr) {
|
|
90
|
+
const libproc = dlopen("libSystem.B.dylib", {
|
|
91
|
+
proc_name: { args: [FFIType.i32, FFIType.ptr, FFIType.u32], returns: FFIType.i32 },
|
|
92
|
+
});
|
|
93
|
+
const name = new Uint8Array(256);
|
|
94
|
+
const decoder = new TextDecoder();
|
|
95
|
+
return (pid) => {
|
|
96
|
+
name.fill(0);
|
|
97
|
+
const written = libproc.symbols.proc_name(pid, ptr(name), name.length);
|
|
98
|
+
return written <= 0 ? "" : decoder.decode(name.subarray(0, written));
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
/** `/proc/<pid>/stat` field 4 is the parent pid; zombies keep their entry. */
|
|
102
|
+
function linuxChildren() {
|
|
103
|
+
const children = [];
|
|
104
|
+
for (const entry of readdirSync("/proc")) {
|
|
105
|
+
const pid = Number(entry);
|
|
106
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
107
|
+
continue;
|
|
108
|
+
if (parentOf(pid) === process.pid)
|
|
109
|
+
children.push(pid);
|
|
110
|
+
if (children.length >= MAX_TRACKED_CHILDREN)
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
return children;
|
|
114
|
+
}
|
|
115
|
+
function linuxStat(pid) {
|
|
116
|
+
try {
|
|
117
|
+
return readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
// The process exited between the directory listing and this read.
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
function parentOf(pid) {
|
|
125
|
+
// comm is parenthesised and may contain spaces, so parse after its last ')'.
|
|
126
|
+
const stat = linuxStat(pid);
|
|
127
|
+
const tail = stat?.slice(stat.lastIndexOf(")") + 2).split(" ");
|
|
128
|
+
return tail === undefined ? undefined : Number(tail[1]);
|
|
129
|
+
}
|
|
130
|
+
function linuxName(pid) {
|
|
131
|
+
const stat = linuxStat(pid);
|
|
132
|
+
const open = stat?.indexOf("(") ?? -1;
|
|
133
|
+
const close = stat?.lastIndexOf(")") ?? -1;
|
|
134
|
+
return stat === undefined || open === -1 || close <= open ? "" : stat.slice(open + 1, close);
|
|
135
|
+
}
|
|
136
|
+
//# sourceMappingURL=child-reaper-syscalls.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"child-reaper-syscalls.js","sourceRoot":"","sources":["../../../src/modes/rpc/child-reaper-syscalls.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,OAAO,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAapD,qFAAqF;AACrF,MAAM,KAAK,GAAG,CAAC,CAAC;AAChB,MAAM,OAAO,GAAG,IAAI,CAAC;AACrB,MAAM,OAAO,GAAG,IAAI,CAAC;AACrB,MAAM,OAAO,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,KAAK,EAAE,WAAW,EAAW,CAAC;AAC9D,wFAAwF;AACxF,MAAM,aAAa,GAAG,EAAE,MAAM,EAAE,EAAE,EAAE,KAAK,EAAE,EAAE,EAAW,CAAC;AACzD,wFAAwF;AACxF,MAAM,oBAAoB,GAAG,IAAI,CAAC;AAIlC,SAAS,iBAAiB,CAAC,QAAgB;IAC1C,OAAO,QAAQ,KAAK,QAAQ,IAAI,QAAQ,KAAK,OAAO,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,SAAS,CAAC;AAC7E,CAAC;AAED;;;;;GAKG;AACH,MAAM,CAAC,KAAK,UAAU,uBAAuB,CAAC,QAAQ,GAAG,OAAO,CAAC,QAAQ;IACxE,MAAM,SAAS,GAAG,iBAAiB,CAAC,QAAQ,CAAC,CAAC;IAC9C,IAAI,SAAS,KAAK,SAAS;QAAE,OAAO,SAAS,CAAC;IAC9C,IAAI,OAAQ,UAAgC,CAAC,GAAG,KAAK,WAAW;QAAE,OAAO,SAAS,CAAC;IACnF,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,GAAG,EAAE,GAAG,MAAM,MAAM,CAAC,SAAS,CAAC,CAAC;IACzD,MAAM,OAAO,GAAG,SAAS,KAAK,QAAQ,CAAC,CAAC,CAAC,mBAAmB,CAAC,CAAC,CAAC,WAAW,CAAC;IAC3E,MAAM,IAAI,GAAG,MAAM,CAAC,OAAO,EAAE;QAC5B,MAAM,EAAE,EAAE,IAAI,EAAE,CAAC,OAAO,CAAC,GAAG,EAAE,OAAO,CAAC,GAAG,EAAE,OAAO,CAAC,GAAG,EAAE,OAAO,CAAC,GAAG,CAAC,EAAE,OAAO,EAAE,OAAO,CAAC,GAAG,EAAE;QAC5F,OAAO,EAAE,EAAE,IAAI,EAAE,CAAC,OAAO,CAAC,GAAG,EAAE,OAAO,CAAC,GAAG,EAAE,OAAO,CAAC,GAAG,CAAC,EAAE,OAAO,EAAE,OAAO,CAAC,GAAG,EAAE;KAChF,CAAC,CAAC;IACH,MAAM,IAAI,GAAG,IAAI,UAAU,CAAC,GAAG,CAAC,CAAC;IACjC,MAAM,QAAQ,GAAG,IAAI,QAAQ,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;IAC3C,MAAM,MAAM,GAAG,IAAI,UAAU,CAAC,CAAC,CAAC,CAAC;IACjC,MAAM,WAAW,GAAG,OAAO,GAAG,OAAO,GAAG,OAAO,CAAC,SAAS,CAAC,CAAC;IAC3D,MAAM,WAAW,GAAG,aAAa,CAAC,SAAS,CAAC,CAAC;IAC7C,OAAO;QACN,kBAAkB,EAAE,SAAS,KAAK,QAAQ,CAAC,CAAC,CAAC,cAAc,CAAC,MAAM,EAAE,OAAO,EAAE,GAAG,CAAC,CAAC,CAAC,CAAC,aAAa;QACjG,UAAU,CAAC,GAAG;YACb,uEAAuE;YACvE,+DAA+D;YAC/D,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;YACb,MAAM,MAAM,GAAG,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,KAAK,EAAE,GAAG,EAAE,GAAG,CAAC,IAAI,CAAC,EAAE,WAAW,CAAC,CAAC;YACvE,OAAO,MAAM,KAAK,CAAC,IAAI,QAAQ,CAAC,QAAQ,CAAC,WAAW,EAAE,IAAI,CAAC,KAAK,GAAG,CAAC;QACrE,CAAC;QACD,UAAU,CAAC,GAAG;YACb,IAAI,GAAG,IAAI,CAAC;gBAAE,MAAM,IAAI,KAAK,CAAC,uCAAuC,GAAG,EAAE,CAAC,CAAC;YAC5E,OAAO,IAAI,CAAC,OAAO,CAAC,OAAO,CAAC,GAAG,EAAE,GAAG,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC,KAAK,GAAG,CAAC;QAChE,CAAC;QACD,QAAQ,EAAE,SAAS,KAAK,QAAQ,CAAC,CAAC,CAAC,UAAU,CAAC,MAAM,EAAE,OAAO,EAAE,GAAG,CAAC,CAAC,CAAC,CAAC,SAAS;KAC/E,CAAC;AACH,CAAC;AASD,SAAS,cAAc,CAAC,MAAc,EAAE,OAAiB,EAAE,GAAQ;IAClE,MAAM,OAAO,GAAG,MAAM,CAAC,mBAAmB,EAAE;QAC3C,kBAAkB,EAAE,EAAE,IAAI,EAAE,CAAC,OAAO,CAAC,GAAG,EAAE,OAAO,CAAC,GAAG,EAAE,OAAO,CAAC,GAAG,CAAC,EAAE,OAAO,EAAE,OAAO,CAAC,GAAG,EAAE;KAC3F,CAAC,CAAC;IACH,MAAM,IAAI,GAAG,IAAI,UAAU,CAAC,oBAAoB,CAAC,CAAC;IAClD,OAAO,GAAG,EAAE;QACX,MAAM,KAAK,GAAG,OAAO,CAAC,OAAO,CAAC,kBAAkB,CAAC,OAAO,CAAC,GAAG,EAAE,GAAG,CAAC,IAAI,CAAC,EAAE,IAAI,CAAC,UAAU,CAAC,CAAC;QAC1F,OAAO,KAAK,IAAI,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC,EAAE,IAAI,CAAC,GAAG,CAAC,KAAK,EAAE,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC;IACrF,CAAC,CAAC;AACH,CAAC;AAED,SAAS,UAAU,CAAC,MAAc,EAAE,OAAiB,EAAE,GAAQ;IAC9D,MAAM,OAAO,GAAG,MAAM,CAAC,mBAAmB,EAAE;QAC3C,SAAS,EAAE,EAAE,IAAI,EAAE,CAAC,OAAO,CAAC,GAAG,EAAE,OAAO,CAAC,GAAG,EAAE,OAAO,CAAC,GAAG,CAAC,EAAE,OAAO,EAAE,OAAO,CAAC,GAAG,EAAE;KAClF,CAAC,CAAC;IACH,MAAM,IAAI,GAAG,IAAI,UAAU,CAAC,GAAG,CAAC,CAAC;IACjC,MAAM,OAAO,GAAG,IAAI,WAAW,EAAE,CAAC;IAClC,OAAO,CAAC,GAAG,EAAE,EAAE;QACd,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QACb,MAAM,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,SAAS,CAAC,GAAG,EAAE,GAAG,CAAC,IAAI,CAAC,EAAE,IAAI,CAAC,MAAM,CAAC,CAAC;QACvE,OAAO,OAAO,IAAI,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,MAAM,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC,EAAE,OAAO,CAAC,CAAC,CAAC;IACtE,CAAC,CAAC;AACH,CAAC;AAED,8EAA8E;AAC9E,SAAS,aAAa;IACrB,MAAM,QAAQ,GAAa,EAAE,CAAC;IAC9B,KAAK,MAAM,KAAK,IAAI,WAAW,CAAC,OAAO,CAAC,EAAE,CAAC;QAC1C,MAAM,GAAG,GAAG,MAAM,CAAC,KAAK,CAAC,CAAC;QAC1B,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,GAAG,CAAC,IAAI,GAAG,IAAI,CAAC;YAAE,SAAS;QACjD,IAAI,QAAQ,CAAC,GAAG,CAAC,KAAK,OAAO,CAAC,GAAG;YAAE,QAAQ,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;QACtD,IAAI,QAAQ,CAAC,MAAM,IAAI,oBAAoB;YAAE,MAAM;IACpD,CAAC;IACD,OAAO,QAAQ,CAAC;AACjB,CAAC;AAED,SAAS,SAAS,CAAC,GAAW;IAC7B,IAAI,CAAC;QACJ,OAAO,YAAY,CAAC,SAAS,GAAG,OAAO,EAAE,MAAM,CAAC,CAAC;IAClD,CAAC;IAAC,MAAM,CAAC;QACR,kEAAkE;QAClE,OAAO,SAAS,CAAC;IAClB,CAAC;AACF,CAAC;AAED,SAAS,QAAQ,CAAC,GAAW;IAC5B,6EAA6E;IAC7E,MAAM,IAAI,GAAG,SAAS,CAAC,GAAG,CAAC,CAAC;IAC5B,MAAM,IAAI,GAAG,IAAI,EAAE,KAAK,CAAC,IAAI,CAAC,WAAW,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;IAC/D,OAAO,IAAI,KAAK,SAAS,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC;AACzD,CAAC;AAED,SAAS,SAAS,CAAC,GAAW;IAC7B,MAAM,IAAI,GAAG,SAAS,CAAC,GAAG,CAAC,CAAC;IAC5B,MAAM,IAAI,GAAG,IAAI,EAAE,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC;IACtC,MAAM,KAAK,GAAG,IAAI,EAAE,WAAW,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC;IAC3C,OAAO,IAAI,KAAK,SAAS,IAAI,IAAI,KAAK,CAAC,CAAC,IAAI,KAAK,IAAI,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,GAAG,CAAC,EAAE,KAAK,CAAC,CAAC;AAC9F,CAAC","sourcesContent":["/**\n * Process-table access for the host child reaper.\n *\n * Three operations, each with exactly one job:\n * - `listDirectChildren` enumerates the DIRECT children of this process\n * (darwin: libproc `proc_listchildpids`, which lists zombies too; linux:\n * a `/proc/<pid>/stat` ppid scan). Never a `ps` spawn - a reaper that\n * spawns children to find children is the bug it is meant to fix (#1507).\n * - `isWaitable` is the ORACLE: `waitid(P_PID, pid, WEXITED|WNOHANG|WNOWAIT)`\n * peeks at the exit status WITHOUT consuming it, so an observation never\n * steals a child from the thread that owns it. `proc_pidinfo` is NOT used\n * for this: it reports nothing trustworthy about a zombie.\n * - `reapExited` consumes exactly one pid with `waitpid(pid, WNOHANG)`.\n * `waitpid(-1, ...)` is never called: it would steal an arbitrary child\n * from whichever thread was waiting for it.\n *\n * RUNTIME BOUNDARY (the repository's one sanctioned reason for a dynamic\n * import, same shape as `ownership-safe-lock.ts`'s `bun:sqlite` loader): the\n * bindings need `bun:ffi`, a Bun-only builtin. A TOP-LEVEL import of it makes\n * this module - and therefore every host that reaches it - unloadable on Node\n * (`ERR_UNSUPPORTED_ESM_URL_SCHEME: Received protocol 'bun:'`, verified). The\n * specifier is therefore imported behind the runtime gate in\n * `loadChildReaperSyscalls`, which resolves to `undefined` on Node so the\n * reaper turns itself off with one warning. Everything else here is top-level.\n */\nimport { readdirSync, readFileSync } from \"node:fs\";\n\n/** The process-table operations the reaper needs; one implementation per platform. */\nexport interface ChildReaperSyscalls {\n\tlistDirectChildren(): readonly number[];\n\t/** True when the child has exited and its status is still unclaimed. */\n\tisWaitable(pid: number): boolean;\n\t/** True when THIS call consumed the child's exit status. */\n\treapExited(pid: number): boolean;\n\t/** Executable name while the child is alive; \"\" once the kernel dropped it. */\n\tdescribe(pid: number): string;\n}\n\n/** waitid/waitpid option bits and idtype, which differ between the two platforms. */\nconst P_PID = 1;\nconst WNOHANG = 0x01;\nconst WEXITED = 0x04;\nconst WNOWAIT = { darwin: 0x20, linux: 0x0100_0000 } as const;\n/** Byte offset of `si_pid` inside `siginfo_t` (darwin packs it right after si_code). */\nconst SI_PID_OFFSET = { darwin: 12, linux: 16 } as const;\n/** A host with more live children than this is pathological; the scan stays bounded. */\nconst MAX_TRACKED_CHILDREN = 4096;\n\ntype SupportedPlatform = keyof typeof WNOWAIT;\n\nfunction supportedPlatform(platform: string): SupportedPlatform | undefined {\n\treturn platform === \"darwin\" || platform === \"linux\" ? platform : undefined;\n}\n\n/**\n * Loads the platform bindings, or `undefined` when this runtime cannot reap\n * (Node, Windows, or a libc without the symbols). The platform and the runtime\n * are both decided BEFORE the `bun:ffi` specifier is reached, so a Node or\n * win32 host never asks its loader for a module it cannot resolve.\n */\nexport async function loadChildReaperSyscalls(platform = process.platform): Promise<ChildReaperSyscalls | undefined> {\n\tconst supported = supportedPlatform(platform);\n\tif (supported === undefined) return undefined;\n\tif (typeof (globalThis as { Bun?: unknown }).Bun === \"undefined\") return undefined;\n\tconst { dlopen, FFIType, ptr } = await import(\"bun:ffi\");\n\tconst library = supported === \"darwin\" ? \"libSystem.B.dylib\" : \"libc.so.6\";\n\tconst wait = dlopen(library, {\n\t\twaitid: { args: [FFIType.i32, FFIType.u32, FFIType.ptr, FFIType.i32], returns: FFIType.i32 },\n\t\twaitpid: { args: [FFIType.i32, FFIType.ptr, FFIType.i32], returns: FFIType.i32 },\n\t});\n\tconst info = new Uint8Array(512);\n\tconst infoView = new DataView(info.buffer);\n\tconst status = new Int32Array(1);\n\tconst peekOptions = WEXITED | WNOHANG | WNOWAIT[supported];\n\tconst siPidOffset = SI_PID_OFFSET[supported];\n\treturn {\n\t\tlistDirectChildren: supported === \"darwin\" ? darwinChildren(dlopen, FFIType, ptr) : linuxChildren,\n\t\tisWaitable(pid) {\n\t\t\t// Zeroed before every call: with WNOHANG the kernel leaves si_pid at 0\n\t\t\t// when the child has not exited, and that is the whole signal.\n\t\t\tinfo.fill(0);\n\t\t\tconst result = wait.symbols.waitid(P_PID, pid, ptr(info), peekOptions);\n\t\t\treturn result === 0 && infoView.getInt32(siPidOffset, true) === pid;\n\t\t},\n\t\treapExited(pid) {\n\t\t\tif (pid <= 0) throw new Error(`child reaper refuses to wait on pid ${pid}`);\n\t\t\treturn wait.symbols.waitpid(pid, ptr(status), WNOHANG) === pid;\n\t\t},\n\t\tdescribe: supported === \"darwin\" ? darwinName(dlopen, FFIType, ptr) : linuxName,\n\t};\n}\n\n// libproc lives in the same library as `waitid`, but its symbols exist only on\n// darwin: opening it per concern keeps each platform's symbol map exact (dlopen\n// returns the already-loaded handle, so this costs nothing at runtime).\ntype Dlopen = Awaited<typeof import(\"bun:ffi\")>[\"dlopen\"];\ntype FFITypes = Awaited<typeof import(\"bun:ffi\")>[\"FFIType\"];\ntype Ptr = Awaited<typeof import(\"bun:ffi\")>[\"ptr\"];\n\nfunction darwinChildren(dlopen: Dlopen, FFIType: FFITypes, ptr: Ptr): () => readonly number[] {\n\tconst libproc = dlopen(\"libSystem.B.dylib\", {\n\t\tproc_listchildpids: { args: [FFIType.i32, FFIType.ptr, FFIType.i32], returns: FFIType.i32 },\n\t});\n\tconst pids = new Int32Array(MAX_TRACKED_CHILDREN);\n\treturn () => {\n\t\tconst found = libproc.symbols.proc_listchildpids(process.pid, ptr(pids), pids.byteLength);\n\t\treturn found <= 0 ? [] : Array.from(pids.subarray(0, Math.min(found, pids.length)));\n\t};\n}\n\nfunction darwinName(dlopen: Dlopen, FFIType: FFITypes, ptr: Ptr): (pid: number) => string {\n\tconst libproc = dlopen(\"libSystem.B.dylib\", {\n\t\tproc_name: { args: [FFIType.i32, FFIType.ptr, FFIType.u32], returns: FFIType.i32 },\n\t});\n\tconst name = new Uint8Array(256);\n\tconst decoder = new TextDecoder();\n\treturn (pid) => {\n\t\tname.fill(0);\n\t\tconst written = libproc.symbols.proc_name(pid, ptr(name), name.length);\n\t\treturn written <= 0 ? \"\" : decoder.decode(name.subarray(0, written));\n\t};\n}\n\n/** `/proc/<pid>/stat` field 4 is the parent pid; zombies keep their entry. */\nfunction linuxChildren(): readonly number[] {\n\tconst children: number[] = [];\n\tfor (const entry of readdirSync(\"/proc\")) {\n\t\tconst pid = Number(entry);\n\t\tif (!Number.isInteger(pid) || pid <= 0) continue;\n\t\tif (parentOf(pid) === process.pid) children.push(pid);\n\t\tif (children.length >= MAX_TRACKED_CHILDREN) break;\n\t}\n\treturn children;\n}\n\nfunction linuxStat(pid: number): string | undefined {\n\ttry {\n\t\treturn readFileSync(`/proc/${pid}/stat`, \"utf8\");\n\t} catch {\n\t\t// The process exited between the directory listing and this read.\n\t\treturn undefined;\n\t}\n}\n\nfunction parentOf(pid: number): number | undefined {\n\t// comm is parenthesised and may contain spaces, so parse after its last ')'.\n\tconst stat = linuxStat(pid);\n\tconst tail = stat?.slice(stat.lastIndexOf(\")\") + 2).split(\" \");\n\treturn tail === undefined ? undefined : Number(tail[1]);\n}\n\nfunction linuxName(pid: number): string {\n\tconst stat = linuxStat(pid);\n\tconst open = stat?.indexOf(\"(\") ?? -1;\n\tconst close = stat?.lastIndexOf(\")\") ?? -1;\n\treturn stat === undefined || open === -1 || close <= open ? \"\" : stat.slice(open + 1, close);\n}\n"]}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reaps the exited DIRECT children of the host process that no thread is going
|
|
3
|
+
* to wait on.
|
|
4
|
+
*
|
|
5
|
+
* Why the host needs this at all (measured, see the matrix in the tracker): a
|
|
6
|
+
* `worker_threads` Worker owns the exit watchers of every child IT spawned, and
|
|
7
|
+
* `worker.terminate()` destroys those watchers with the thread - the session
|
|
8
|
+
* worker quarantine (`session-worker-client.ts`) is exactly that call. Every
|
|
9
|
+
* spawn API leaks the same way (node `child_process.spawn`, `Bun.spawn`,
|
|
10
|
+
* `Bun.$`), under bun from source, under the compiled binary, and under Node,
|
|
11
|
+
* and the zombies survive for the life of the host. Children spawned from a
|
|
12
|
+
* LIVE thread are reaped by their own runtime and never reach this code.
|
|
13
|
+
*
|
|
14
|
+
* Why it is safe: a zombie carries no hint about which thread meant to wait on
|
|
15
|
+
* it, so the only protection against STEALING a child from a live waiter is
|
|
16
|
+
* time. A thread blocked in a synchronous call cannot reap its own exited child
|
|
17
|
+
* until it unblocks, and stealing that child breaks the owner's contract - the
|
|
18
|
+
* measurement shows `child_process` and `Bun.spawn` then reject with ECHILD and
|
|
19
|
+
* `Bun.$` never settles at all. The reaper therefore waits for a pid to stay
|
|
20
|
+
* waitable across two ticks at least `minWaitableMs` apart, defaulting far above
|
|
21
|
+
* the longest synchronous block we measured, and it always peeks with
|
|
22
|
+
* `waitid(..., WNOWAIT)` before it consumes anything.
|
|
23
|
+
*/
|
|
24
|
+
import { type ChildReaperSyscalls } from "./child-reaper-syscalls.ts";
|
|
25
|
+
/** Set to `0` to turn the reaper off; anything else leaves it on. */
|
|
26
|
+
export declare const CHILD_REAPER_ENV = "SENPI_RPC_HOST_REAPER";
|
|
27
|
+
/** Overrides how long a child must stay waitable before it is considered abandoned. */
|
|
28
|
+
export declare const CHILD_REAPER_MIN_WAITABLE_MS_ENV = "SENPI_RPC_HOST_REAPER_MIN_WAITABLE_MS";
|
|
29
|
+
/** Hard floor: a pid waitable for less than this is never touched. */
|
|
30
|
+
export declare const MIN_WAITABLE_FLOOR_MS = 5000;
|
|
31
|
+
export interface ChildReaperConfig {
|
|
32
|
+
readonly enabled: boolean;
|
|
33
|
+
readonly tickMs: number;
|
|
34
|
+
readonly minWaitableMs: number;
|
|
35
|
+
}
|
|
36
|
+
export interface ChildReaperOptions {
|
|
37
|
+
readonly syscalls: ChildReaperSyscalls;
|
|
38
|
+
readonly now?: () => number;
|
|
39
|
+
readonly minWaitableMs?: number;
|
|
40
|
+
readonly log?: (message: string) => void;
|
|
41
|
+
}
|
|
42
|
+
export interface ChildReaper {
|
|
43
|
+
/** One pass over the direct children: observe, then reap what aged out. */
|
|
44
|
+
tick(): void;
|
|
45
|
+
/** Children seen exited and still unclaimed, oldest observation first. */
|
|
46
|
+
readonly waitingPids: readonly number[];
|
|
47
|
+
}
|
|
48
|
+
export declare function resolveChildReaperConfig(env: Readonly<Record<string, string | undefined>>): ChildReaperConfig;
|
|
49
|
+
export declare function createChildReaper(options: ChildReaperOptions): ChildReaper;
|
|
50
|
+
/**
|
|
51
|
+
* Arms the reaper on the host loop. The tick is unref'd, so it never keeps an
|
|
52
|
+
* otherwise idle host alive, and the whole thing is one `setInterval` the caller
|
|
53
|
+
* stops at shutdown.
|
|
54
|
+
*/
|
|
55
|
+
export declare function startHostChildReaper(log: (message: string) => void): Promise<() => void>;
|
|
56
|
+
//# sourceMappingURL=child-reaper.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"child-reaper.d.ts","sourceRoot":"","sources":["../../../src/modes/rpc/child-reaper.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,OAAO,EAAE,KAAK,mBAAmB,EAA2B,MAAM,4BAA4B,CAAC;AAE/F,qEAAqE;AACrE,eAAO,MAAM,gBAAgB,0BAA0B,CAAC;AACxD,uFAAuF;AACvF,eAAO,MAAM,gCAAgC,0CAA0C,CAAC;AACxF,sEAAsE;AACtE,eAAO,MAAM,qBAAqB,OAAQ,CAAC;AAS3C,MAAM,WAAW,iBAAiB;IACjC,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;CAC/B;AAED,MAAM,WAAW,kBAAkB;IAClC,QAAQ,CAAC,QAAQ,EAAE,mBAAmB,CAAC;IACvC,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;IAC5B,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,CAAC;IAChC,QAAQ,CAAC,GAAG,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;CACzC;AAED,MAAM,WAAW,WAAW;IAC3B,2EAA2E;IAC3E,IAAI,IAAI,IAAI,CAAC;IACb,0EAA0E;IAC1E,QAAQ,CAAC,WAAW,EAAE,SAAS,MAAM,EAAE,CAAC;CACxC;AAOD,wBAAgB,wBAAwB,CAAC,GAAG,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC,GAAG,iBAAiB,CAO7G;AAMD,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,kBAAkB,GAAG,WAAW,CAgD1E;AAmBD;;;;GAIG;AACH,wBAAsB,oBAAoB,CAAC,GAAG,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,GAAG,OAAO,CAAC,MAAM,IAAI,CAAC,CAe9F"}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reaps the exited DIRECT children of the host process that no thread is going
|
|
3
|
+
* to wait on.
|
|
4
|
+
*
|
|
5
|
+
* Why the host needs this at all (measured, see the matrix in the tracker): a
|
|
6
|
+
* `worker_threads` Worker owns the exit watchers of every child IT spawned, and
|
|
7
|
+
* `worker.terminate()` destroys those watchers with the thread - the session
|
|
8
|
+
* worker quarantine (`session-worker-client.ts`) is exactly that call. Every
|
|
9
|
+
* spawn API leaks the same way (node `child_process.spawn`, `Bun.spawn`,
|
|
10
|
+
* `Bun.$`), under bun from source, under the compiled binary, and under Node,
|
|
11
|
+
* and the zombies survive for the life of the host. Children spawned from a
|
|
12
|
+
* LIVE thread are reaped by their own runtime and never reach this code.
|
|
13
|
+
*
|
|
14
|
+
* Why it is safe: a zombie carries no hint about which thread meant to wait on
|
|
15
|
+
* it, so the only protection against STEALING a child from a live waiter is
|
|
16
|
+
* time. A thread blocked in a synchronous call cannot reap its own exited child
|
|
17
|
+
* until it unblocks, and stealing that child breaks the owner's contract - the
|
|
18
|
+
* measurement shows `child_process` and `Bun.spawn` then reject with ECHILD and
|
|
19
|
+
* `Bun.$` never settles at all. The reaper therefore waits for a pid to stay
|
|
20
|
+
* waitable across two ticks at least `minWaitableMs` apart, defaulting far above
|
|
21
|
+
* the longest synchronous block we measured, and it always peeks with
|
|
22
|
+
* `waitid(..., WNOWAIT)` before it consumes anything.
|
|
23
|
+
*/
|
|
24
|
+
import { loadChildReaperSyscalls } from "./child-reaper-syscalls.js";
|
|
25
|
+
/** Set to `0` to turn the reaper off; anything else leaves it on. */
|
|
26
|
+
export const CHILD_REAPER_ENV = "SENPI_RPC_HOST_REAPER";
|
|
27
|
+
/** Overrides how long a child must stay waitable before it is considered abandoned. */
|
|
28
|
+
export const CHILD_REAPER_MIN_WAITABLE_MS_ENV = "SENPI_RPC_HOST_REAPER_MIN_WAITABLE_MS";
|
|
29
|
+
/** Hard floor: a pid waitable for less than this is never touched. */
|
|
30
|
+
export const MIN_WAITABLE_FLOOR_MS = 5_000;
|
|
31
|
+
/** Default window: 2.5x the longest synchronous block the matrix exercises (12 s). */
|
|
32
|
+
const DEFAULT_MIN_WAITABLE_MS = 30_000;
|
|
33
|
+
const TICK_MS = 1_000;
|
|
34
|
+
const WARN_INTERVAL_MS = 5 * 60_000;
|
|
35
|
+
/** Below this many waiting children a quiet host stays quiet. */
|
|
36
|
+
const WARN_AT_WAITING = 10;
|
|
37
|
+
const TOP_NAMES = 3;
|
|
38
|
+
export function resolveChildReaperConfig(env) {
|
|
39
|
+
const override = Number(env[CHILD_REAPER_MIN_WAITABLE_MS_ENV]);
|
|
40
|
+
return {
|
|
41
|
+
enabled: env[CHILD_REAPER_ENV] !== "0",
|
|
42
|
+
tickMs: TICK_MS,
|
|
43
|
+
minWaitableMs: Number.isFinite(override) && override > 0 ? clampWindow(override) : DEFAULT_MIN_WAITABLE_MS,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
function clampWindow(milliseconds) {
|
|
47
|
+
return Math.max(MIN_WAITABLE_FLOOR_MS, milliseconds);
|
|
48
|
+
}
|
|
49
|
+
export function createChildReaper(options) {
|
|
50
|
+
const now = options.now ?? Date.now;
|
|
51
|
+
const minWaitableMs = clampWindow(options.minWaitableMs ?? DEFAULT_MIN_WAITABLE_MS);
|
|
52
|
+
const tracked = new Map();
|
|
53
|
+
let lastWarnAt;
|
|
54
|
+
const report = (waiting, reaped) => {
|
|
55
|
+
if (options.log === undefined || (reaped.length === 0 && waiting.length < WARN_AT_WAITING))
|
|
56
|
+
return;
|
|
57
|
+
if (lastWarnAt !== undefined && now() - lastWarnAt < WARN_INTERVAL_MS)
|
|
58
|
+
return;
|
|
59
|
+
lastWarnAt = now();
|
|
60
|
+
options.log(`child reaper: reaped=${reaped.length} waiting=${waiting.length} top=${topNames(waiting, reaped)}`);
|
|
61
|
+
};
|
|
62
|
+
return {
|
|
63
|
+
tick() {
|
|
64
|
+
const children = options.syscalls.listDirectChildren();
|
|
65
|
+
const present = new Set(children);
|
|
66
|
+
for (const pid of [...tracked.keys()])
|
|
67
|
+
if (!present.has(pid))
|
|
68
|
+
tracked.delete(pid);
|
|
69
|
+
const reaped = [];
|
|
70
|
+
for (const pid of children) {
|
|
71
|
+
// A child first seen as a zombie has no name left to read: the kernel
|
|
72
|
+
// keeps the exit status, not the executable.
|
|
73
|
+
const child = tracked.get(pid) ?? {
|
|
74
|
+
name: options.syscalls.describe(pid) || "unknown",
|
|
75
|
+
waitableSince: undefined,
|
|
76
|
+
};
|
|
77
|
+
tracked.set(pid, child);
|
|
78
|
+
// The oracle: WNOWAIT peeks at the exit status without consuming it,
|
|
79
|
+
// so a live child's owner keeps every right it had before this pass.
|
|
80
|
+
if (!options.syscalls.isWaitable(pid)) {
|
|
81
|
+
child.waitableSince = undefined;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (child.waitableSince === undefined) {
|
|
85
|
+
child.waitableSince = now();
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (now() - child.waitableSince < minWaitableMs)
|
|
89
|
+
continue;
|
|
90
|
+
if (!options.syscalls.reapExited(pid))
|
|
91
|
+
continue;
|
|
92
|
+
tracked.delete(pid);
|
|
93
|
+
reaped.push(child.name);
|
|
94
|
+
}
|
|
95
|
+
report(waitingNames(tracked), reaped);
|
|
96
|
+
},
|
|
97
|
+
get waitingPids() {
|
|
98
|
+
return [...tracked].filter(([, child]) => child.waitableSince !== undefined).map(([pid]) => pid);
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
function waitingNames(tracked) {
|
|
103
|
+
return [...tracked.values()].filter((child) => child.waitableSince !== undefined).map((child) => child.name);
|
|
104
|
+
}
|
|
105
|
+
/** The commonest command names among the children this pass accounted for. */
|
|
106
|
+
function topNames(waiting, reaped) {
|
|
107
|
+
const counts = new Map();
|
|
108
|
+
for (const name of [...waiting, ...reaped]) {
|
|
109
|
+
counts.set(name, (counts.get(name) ?? 0) + 1);
|
|
110
|
+
}
|
|
111
|
+
return [...counts]
|
|
112
|
+
.sort(([, left], [, right]) => right - left)
|
|
113
|
+
.slice(0, TOP_NAMES)
|
|
114
|
+
.map(([name, count]) => `${name} x${count}`)
|
|
115
|
+
.join(", ");
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Arms the reaper on the host loop. The tick is unref'd, so it never keeps an
|
|
119
|
+
* otherwise idle host alive, and the whole thing is one `setInterval` the caller
|
|
120
|
+
* stops at shutdown.
|
|
121
|
+
*/
|
|
122
|
+
export async function startHostChildReaper(log) {
|
|
123
|
+
const config = resolveChildReaperConfig(process.env);
|
|
124
|
+
if (!config.enabled)
|
|
125
|
+
return () => { };
|
|
126
|
+
const syscalls = await loadChildReaperSyscalls();
|
|
127
|
+
if (syscalls === undefined) {
|
|
128
|
+
log(`child reaper unavailable under ${runtimeName()}: children orphaned by a terminated worker thread ` +
|
|
129
|
+
`stay as zombies until this host exits`);
|
|
130
|
+
return () => { };
|
|
131
|
+
}
|
|
132
|
+
const reaper = createChildReaper({ syscalls, minWaitableMs: config.minWaitableMs, log });
|
|
133
|
+
const timer = setInterval(() => reaper.tick(), config.tickMs);
|
|
134
|
+
timer.unref();
|
|
135
|
+
return () => clearInterval(timer);
|
|
136
|
+
}
|
|
137
|
+
function runtimeName() {
|
|
138
|
+
return typeof globalThis.Bun === "undefined" ? "Node" : `Bun on ${process.platform}`;
|
|
139
|
+
}
|
|
140
|
+
//# sourceMappingURL=child-reaper.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"child-reaper.js","sourceRoot":"","sources":["../../../src/modes/rpc/child-reaper.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Reaps the exited DIRECT children of the host process that no thread is going\n * to wait on.\n *\n * Why the host needs this at all (measured, see the matrix in the tracker): a\n * `worker_threads` Worker owns the exit watchers of every child IT spawned, and\n * `worker.terminate()` destroys those watchers with the thread - the session\n * worker quarantine (`session-worker-client.ts`) is exactly that call. Every\n * spawn API leaks the same way (node `child_process.spawn`, `Bun.spawn`,\n * `Bun.$`), under bun from source, under the compiled binary, and under Node,\n * and the zombies survive for the life of the host. Children spawned from a\n * LIVE thread are reaped by their own runtime and never reach this code.\n *\n * Why it is safe: a zombie carries no hint about which thread meant to wait on\n * it, so the only protection against STEALING a child from a live waiter is\n * time. A thread blocked in a synchronous call cannot reap its own exited child\n * until it unblocks, and stealing that child breaks the owner's contract - the\n * measurement shows `child_process` and `Bun.spawn` then reject with ECHILD and\n * `Bun.$` never settles at all. The reaper therefore waits for a pid to stay\n * waitable across two ticks at least `minWaitableMs` apart, defaulting far above\n * the longest synchronous block we measured, and it always peeks with\n * `waitid(..., WNOWAIT)` before it consumes anything.\n */\nimport { type ChildReaperSyscalls, loadChildReaperSyscalls } from \"./child-reaper-syscalls.ts\";\n\n/** Set to `0` to turn the reaper off; anything else leaves it on. */\nexport const CHILD_REAPER_ENV = \"SENPI_RPC_HOST_REAPER\";\n/** Overrides how long a child must stay waitable before it is considered abandoned. */\nexport const CHILD_REAPER_MIN_WAITABLE_MS_ENV = \"SENPI_RPC_HOST_REAPER_MIN_WAITABLE_MS\";\n/** Hard floor: a pid waitable for less than this is never touched. */\nexport const MIN_WAITABLE_FLOOR_MS = 5_000;\n/** Default window: 2.5x the longest synchronous block the matrix exercises (12 s). */\nconst DEFAULT_MIN_WAITABLE_MS = 30_000;\nconst TICK_MS = 1_000;\nconst WARN_INTERVAL_MS = 5 * 60_000;\n/** Below this many waiting children a quiet host stays quiet. */\nconst WARN_AT_WAITING = 10;\nconst TOP_NAMES = 3;\n\nexport interface ChildReaperConfig {\n\treadonly enabled: boolean;\n\treadonly tickMs: number;\n\treadonly minWaitableMs: number;\n}\n\nexport interface ChildReaperOptions {\n\treadonly syscalls: ChildReaperSyscalls;\n\treadonly now?: () => number;\n\treadonly minWaitableMs?: number;\n\treadonly log?: (message: string) => void;\n}\n\nexport interface ChildReaper {\n\t/** One pass over the direct children: observe, then reap what aged out. */\n\ttick(): void;\n\t/** Children seen exited and still unclaimed, oldest observation first. */\n\treadonly waitingPids: readonly number[];\n}\n\ninterface TrackedChild {\n\tname: string;\n\twaitableSince: number | undefined;\n}\n\nexport function resolveChildReaperConfig(env: Readonly<Record<string, string | undefined>>): ChildReaperConfig {\n\tconst override = Number(env[CHILD_REAPER_MIN_WAITABLE_MS_ENV]);\n\treturn {\n\t\tenabled: env[CHILD_REAPER_ENV] !== \"0\",\n\t\ttickMs: TICK_MS,\n\t\tminWaitableMs: Number.isFinite(override) && override > 0 ? clampWindow(override) : DEFAULT_MIN_WAITABLE_MS,\n\t};\n}\n\nfunction clampWindow(milliseconds: number): number {\n\treturn Math.max(MIN_WAITABLE_FLOOR_MS, milliseconds);\n}\n\nexport function createChildReaper(options: ChildReaperOptions): ChildReaper {\n\tconst now = options.now ?? Date.now;\n\tconst minWaitableMs = clampWindow(options.minWaitableMs ?? DEFAULT_MIN_WAITABLE_MS);\n\tconst tracked = new Map<number, TrackedChild>();\n\tlet lastWarnAt: number | undefined;\n\n\tconst report = (waiting: readonly string[], reaped: readonly string[]): void => {\n\t\tif (options.log === undefined || (reaped.length === 0 && waiting.length < WARN_AT_WAITING)) return;\n\t\tif (lastWarnAt !== undefined && now() - lastWarnAt < WARN_INTERVAL_MS) return;\n\t\tlastWarnAt = now();\n\t\toptions.log(`child reaper: reaped=${reaped.length} waiting=${waiting.length} top=${topNames(waiting, reaped)}`);\n\t};\n\n\treturn {\n\t\ttick() {\n\t\t\tconst children = options.syscalls.listDirectChildren();\n\t\t\tconst present = new Set(children);\n\t\t\tfor (const pid of [...tracked.keys()]) if (!present.has(pid)) tracked.delete(pid);\n\t\t\tconst reaped: string[] = [];\n\t\t\tfor (const pid of children) {\n\t\t\t\t// A child first seen as a zombie has no name left to read: the kernel\n\t\t\t\t// keeps the exit status, not the executable.\n\t\t\t\tconst child = tracked.get(pid) ?? {\n\t\t\t\t\tname: options.syscalls.describe(pid) || \"unknown\",\n\t\t\t\t\twaitableSince: undefined,\n\t\t\t\t};\n\t\t\t\ttracked.set(pid, child);\n\t\t\t\t// The oracle: WNOWAIT peeks at the exit status without consuming it,\n\t\t\t\t// so a live child's owner keeps every right it had before this pass.\n\t\t\t\tif (!options.syscalls.isWaitable(pid)) {\n\t\t\t\t\tchild.waitableSince = undefined;\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t\tif (child.waitableSince === undefined) {\n\t\t\t\t\tchild.waitableSince = now();\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t\tif (now() - child.waitableSince < minWaitableMs) continue;\n\t\t\t\tif (!options.syscalls.reapExited(pid)) continue;\n\t\t\t\ttracked.delete(pid);\n\t\t\t\treaped.push(child.name);\n\t\t\t}\n\t\t\treport(waitingNames(tracked), reaped);\n\t\t},\n\t\tget waitingPids() {\n\t\t\treturn [...tracked].filter(([, child]) => child.waitableSince !== undefined).map(([pid]) => pid);\n\t\t},\n\t};\n}\n\nfunction waitingNames(tracked: ReadonlyMap<number, TrackedChild>): readonly string[] {\n\treturn [...tracked.values()].filter((child) => child.waitableSince !== undefined).map((child) => child.name);\n}\n\n/** The commonest command names among the children this pass accounted for. */\nfunction topNames(waiting: readonly string[], reaped: readonly string[]): string {\n\tconst counts = new Map<string, number>();\n\tfor (const name of [...waiting, ...reaped]) {\n\t\tcounts.set(name, (counts.get(name) ?? 0) + 1);\n\t}\n\treturn [...counts]\n\t\t.sort(([, left], [, right]) => right - left)\n\t\t.slice(0, TOP_NAMES)\n\t\t.map(([name, count]) => `${name} x${count}`)\n\t\t.join(\", \");\n}\n\n/**\n * Arms the reaper on the host loop. The tick is unref'd, so it never keeps an\n * otherwise idle host alive, and the whole thing is one `setInterval` the caller\n * stops at shutdown.\n */\nexport async function startHostChildReaper(log: (message: string) => void): Promise<() => void> {\n\tconst config = resolveChildReaperConfig(process.env);\n\tif (!config.enabled) return () => {};\n\tconst syscalls = await loadChildReaperSyscalls();\n\tif (syscalls === undefined) {\n\t\tlog(\n\t\t\t`child reaper unavailable under ${runtimeName()}: children orphaned by a terminated worker thread ` +\n\t\t\t\t`stay as zombies until this host exits`,\n\t\t);\n\t\treturn () => {};\n\t}\n\tconst reaper = createChildReaper({ syscalls, minWaitableMs: config.minWaitableMs, log });\n\tconst timer = setInterval(() => reaper.tick(), config.tickMs);\n\ttimer.unref();\n\treturn () => clearInterval(timer);\n}\n\nfunction runtimeName(): string {\n\treturn typeof (globalThis as { Bun?: unknown }).Bun === \"undefined\" ? \"Node\" : `Bun on ${process.platform}`;\n}\n"]}
|
|
@@ -32,6 +32,22 @@ export declare const QUESTION_CAPABILITY = "question";
|
|
|
32
32
|
* that flag survives its last client's disconnect instead of being closed with it.
|
|
33
33
|
*/
|
|
34
34
|
export declare const RETAIN_ON_DISCONNECT_CAPABILITY = "retain_on_disconnect";
|
|
35
|
+
/**
|
|
36
|
+
* HOST capability: this host accepts `open_session.context`, hands it to that session's
|
|
37
|
+
* extensions as `pi.sessionContext`, and republishes it on `list_sessions { include_workers: true }`.
|
|
38
|
+
*/
|
|
39
|
+
export declare const SESSION_CONTEXT_CAPABILITY = "session_context";
|
|
40
|
+
/**
|
|
41
|
+
* HOST capability: this host accepts `open_session.kind`, publishes `kind` on every
|
|
42
|
+
* `list_sessions` row, hides `worker` rows unless `include_workers` is set, and keeps a
|
|
43
|
+
* worker session's lifecycle records on the connections attached to it.
|
|
44
|
+
*/
|
|
45
|
+
export declare const SESSION_KIND_CAPABILITY = "session_kind";
|
|
46
|
+
/**
|
|
47
|
+
* HOST capability: this host honors `open_session.auto_title`, so a session can opt
|
|
48
|
+
* into or out of engine-side titling independently of `--auto-title-sessions`.
|
|
49
|
+
*/
|
|
50
|
+
export declare const AUTO_TITLE_PER_SESSION_CAPABILITY = "auto_title_per_session";
|
|
35
51
|
/**
|
|
36
52
|
* Env var carrying client capabilities to a single-connection stdio RPC host
|
|
37
53
|
* (comma-separated). A launcher may set it from a client handshake; a plain
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"custom-capability.d.ts","sourceRoot":"","sources":["../../../src/modes/rpc/custom-capability.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAGH,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,gBAAgB,CAAC;AAE5D,oFAAoF;AACpF,eAAO,MAAM,6BAA6B,uBAAuB,CAAC;AAClE,eAAO,MAAM,2BAA2B,qBAAqB,CAAC;AAC9D,eAAO,MAAM,8BAA8B,wBAAwB,CAAC;AACpE,eAAO,MAAM,8BAA8B,wBAAwB,CAAC;AACpE;;;GAGG;AACH,eAAO,MAAM,6BAA6B,uBAAuB,CAAC;AAClE,iGAAiG;AACjG,eAAO,MAAM,mBAAmB,aAAa,CAAC;AAE9C;;;;GAIG;AACH,eAAO,MAAM,+BAA+B,yBAAyB,CAAC;AAEtE;;;;GAIG;AACH,eAAO,MAAM,2BAA2B,kCAAkC,CAAC;AAE3E,4EAA4E;AAC5E,wBAAgB,uBAAuB,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,EAAE,CAM3E;AAED,6EAA6E;AAC7E,eAAO,MAAM,8BAA8B,wBAAwB,CAAC;AAEpE;;;;;;GAMG;AACH,wBAAgB,6BAA6B,CAC5C,YAAY,EAAE,SAAS,MAAM,EAAE,GAAG,SAAS,EAC3C,aAAa,EAAE,MAAM,GACnB,qBAAqB,GAAG,SAAS,CAWnC"}
|
|
1
|
+
{"version":3,"file":"custom-capability.d.ts","sourceRoot":"","sources":["../../../src/modes/rpc/custom-capability.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAGH,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,gBAAgB,CAAC;AAE5D,oFAAoF;AACpF,eAAO,MAAM,6BAA6B,uBAAuB,CAAC;AAClE,eAAO,MAAM,2BAA2B,qBAAqB,CAAC;AAC9D,eAAO,MAAM,8BAA8B,wBAAwB,CAAC;AACpE,eAAO,MAAM,8BAA8B,wBAAwB,CAAC;AACpE;;;GAGG;AACH,eAAO,MAAM,6BAA6B,uBAAuB,CAAC;AAClE,iGAAiG;AACjG,eAAO,MAAM,mBAAmB,aAAa,CAAC;AAE9C;;;;GAIG;AACH,eAAO,MAAM,+BAA+B,yBAAyB,CAAC;AAEtE;;;GAGG;AACH,eAAO,MAAM,0BAA0B,oBAAoB,CAAC;AAE5D;;;;GAIG;AACH,eAAO,MAAM,uBAAuB,iBAAiB,CAAC;AAEtD;;;GAGG;AACH,eAAO,MAAM,iCAAiC,2BAA2B,CAAC;AAE1E;;;;GAIG;AACH,eAAO,MAAM,2BAA2B,kCAAkC,CAAC;AAE3E,4EAA4E;AAC5E,wBAAgB,uBAAuB,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,EAAE,CAM3E;AAED,6EAA6E;AAC7E,eAAO,MAAM,8BAA8B,wBAAwB,CAAC;AAEpE;;;;;;GAMG;AACH,wBAAgB,6BAA6B,CAC5C,YAAY,EAAE,SAAS,MAAM,EAAE,GAAG,SAAS,EAC3C,aAAa,EAAE,MAAM,GACnB,qBAAqB,GAAG,SAAS,CAWnC"}
|
|
@@ -32,6 +32,22 @@ export const QUESTION_CAPABILITY = "question";
|
|
|
32
32
|
* that flag survives its last client's disconnect instead of being closed with it.
|
|
33
33
|
*/
|
|
34
34
|
export const RETAIN_ON_DISCONNECT_CAPABILITY = "retain_on_disconnect";
|
|
35
|
+
/**
|
|
36
|
+
* HOST capability: this host accepts `open_session.context`, hands it to that session's
|
|
37
|
+
* extensions as `pi.sessionContext`, and republishes it on `list_sessions { include_workers: true }`.
|
|
38
|
+
*/
|
|
39
|
+
export const SESSION_CONTEXT_CAPABILITY = "session_context";
|
|
40
|
+
/**
|
|
41
|
+
* HOST capability: this host accepts `open_session.kind`, publishes `kind` on every
|
|
42
|
+
* `list_sessions` row, hides `worker` rows unless `include_workers` is set, and keeps a
|
|
43
|
+
* worker session's lifecycle records on the connections attached to it.
|
|
44
|
+
*/
|
|
45
|
+
export const SESSION_KIND_CAPABILITY = "session_kind";
|
|
46
|
+
/**
|
|
47
|
+
* HOST capability: this host honors `open_session.auto_title`, so a session can opt
|
|
48
|
+
* into or out of engine-side titling independently of `--auto-title-sessions`.
|
|
49
|
+
*/
|
|
50
|
+
export const AUTO_TITLE_PER_SESSION_CAPABILITY = "auto_title_per_session";
|
|
35
51
|
/**
|
|
36
52
|
* Env var carrying client capabilities to a single-connection stdio RPC host
|
|
37
53
|
* (comma-separated). A launcher may set it from a client handshake; a plain
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"custom-capability.js","sourceRoot":"","sources":["../../../src/modes/rpc/custom-capability.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,KAAK,MAAM,MAAM,aAAa,CAAC;AAGtC,oFAAoF;AACpF,MAAM,CAAC,MAAM,6BAA6B,GAAG,oBAAoB,CAAC;AAClE,MAAM,CAAC,MAAM,2BAA2B,GAAG,kBAAkB,CAAC;AAC9D,MAAM,CAAC,MAAM,8BAA8B,GAAG,qBAAqB,CAAC;AACpE,MAAM,CAAC,MAAM,8BAA8B,GAAG,qBAAqB,CAAC;AACpE;;;GAGG;AACH,MAAM,CAAC,MAAM,6BAA6B,GAAG,oBAAoB,CAAC;AAClE,iGAAiG;AACjG,MAAM,CAAC,MAAM,mBAAmB,GAAG,UAAU,CAAC;AAE9C;;;;GAIG;AACH,MAAM,CAAC,MAAM,+BAA+B,GAAG,sBAAsB,CAAC;AAEtE;;;;GAIG;AACH,MAAM,CAAC,MAAM,2BAA2B,GAAG,+BAA+B,CAAC;AAE3E,4EAA4E;AAC5E,MAAM,UAAU,uBAAuB,CAAC,KAAyB;IAChE,IAAI,CAAC,KAAK;QAAE,OAAO,EAAE,CAAC;IACtB,OAAO,KAAK;SACV,KAAK,CAAC,GAAG,CAAC;SACV,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC;SAC1B,MAAM,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC;AACrC,CAAC;AAED,6EAA6E;AAC7E,MAAM,CAAC,MAAM,8BAA8B,GAAG,qBAAqB,CAAC;AAEpE;;;;;;GAMG;AACH,MAAM,UAAU,6BAA6B,CAC5C,YAA2C,EAC3C,aAAqB;IAErB,IAAI,CAAC,YAAY,EAAE,QAAQ,CAAC,6BAA6B,CAAC,EAAE,CAAC;QAC5D,OAAO,SAAS,CAAC;IAClB,CAAC;IACD,MAAM,IAAI,GAAG,aAAa,CAAC,IAAI,EAAE,IAAI,8BAA8B,CAAC;IACpE,OAAO;QACN,IAAI,EAAE,sBAAsB;QAC5B,EAAE,EAAE,MAAM,CAAC,UAAU,EAAE;QACvB,MAAM,EAAE,oBAAoB;QAC5B,aAAa,EAAE,IAAI;KACnB,CAAC;AACH,CAAC","sourcesContent":["/**\n * The `custom_unsupported` capability gate (plan tasks 13/14).\n *\n * In RPC mode `ctx.ui.custom` cannot render a third-party component — there is no\n * TUI to render it FROM. Historically it returned `undefined` synchronously with\n * NO wire message, so a default RPC client saw nothing at all. A client that\n * can present a native \"this extension UI requires the classic TUI\" notice opts\n * in via a capability flag.\n *\n * This gate is a PURE function so the additive-only guarantee is unit-provable:\n * only a client that advertised the `custom_unsupported` capability gets the\n * additive `extension_ui_request{method:\"custom_unsupported\"}` notice; every\n * other (default) client gets `undefined` — byte-identical to the prior\n * behavior, no extra bytes on the wire.\n */\n\nimport * as crypto from \"node:crypto\";\nimport type { RpcExtensionUIRequest } from \"./rpc-types.ts\";\n\n/** The capability string a client sends in its handshake to opt into the notice. */\nexport const CUSTOM_UNSUPPORTED_CAPABILITY = \"custom_unsupported\";\nexport const EXTENSION_EVENTS_CAPABILITY = \"extension_events\";\nexport const RENDERED_COMPONENTS_CAPABILITY = \"rendered_components\";\nexport const AUTO_TITLE_SESSIONS_CAPABILITY = \"auto_title_sessions\";\n/**\n * Opt-in: the host replaces inline image bytes inside tool results with `image_ref`\n * placeholders for this connection; the client fetches a block on demand with `get_media`.\n */\nexport const MEDIA_PLACEHOLDERS_CAPABILITY = \"media_placeholders\";\n/** Opt-in: the client can present a multi-question `extension_ui_request{method:\"question\"}`. */\nexport const QUESTION_CAPABILITY = \"question\";\n\n/**\n * HOST capability (advertised in `get_protocol_info`, never sent by a client):\n * this host honors `open_session.retain_on_disconnect`, so a session opened with\n * that flag survives its last client's disconnect instead of being closed with it.\n */\nexport const RETAIN_ON_DISCONNECT_CAPABILITY = \"retain_on_disconnect\";\n\n/**\n * Env var carrying client capabilities to a single-connection stdio RPC host\n * (comma-separated). A launcher may set it from a client handshake; a plain\n * stdio client leaves it unset and sees byte-identical default behavior.\n */\nexport const RPC_CLIENT_CAPABILITIES_ENV = \"SENPI_RPC_CLIENT_CAPABILITIES\";\n\n/** Parse the comma-separated capabilities env value into a trimmed list. */\nexport function parseClientCapabilities(value: string | undefined): string[] {\n\tif (!value) return [];\n\treturn value\n\t\t.split(\",\")\n\t\t.map((part) => part.trim())\n\t\t.filter((part) => part.length > 0);\n}\n\n/** Fallback label when the calling extension's name cannot be determined. */\nexport const DEFAULT_CUSTOM_EXTENSION_LABEL = \"custom UI component\";\n\n/**\n * Build the additive `custom_unsupported` request for a `ctx.ui.custom` call, or\n * `undefined` when the client did not opt in.\n *\n * Returning `undefined` is the load-bearing default-client path: the caller must\n * emit NOTHING in that case, preserving the exact prior wire behavior.\n */\nexport function buildCustomUnsupportedRequest(\n\tcapabilities: readonly string[] | undefined,\n\textensionName: string,\n): RpcExtensionUIRequest | undefined {\n\tif (!capabilities?.includes(CUSTOM_UNSUPPORTED_CAPABILITY)) {\n\t\treturn undefined;\n\t}\n\tconst name = extensionName.trim() || DEFAULT_CUSTOM_EXTENSION_LABEL;\n\treturn {\n\t\ttype: \"extension_ui_request\",\n\t\tid: crypto.randomUUID(),\n\t\tmethod: \"custom_unsupported\",\n\t\textensionName: name,\n\t};\n}\n"]}
|
|
1
|
+
{"version":3,"file":"custom-capability.js","sourceRoot":"","sources":["../../../src/modes/rpc/custom-capability.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,KAAK,MAAM,MAAM,aAAa,CAAC;AAGtC,oFAAoF;AACpF,MAAM,CAAC,MAAM,6BAA6B,GAAG,oBAAoB,CAAC;AAClE,MAAM,CAAC,MAAM,2BAA2B,GAAG,kBAAkB,CAAC;AAC9D,MAAM,CAAC,MAAM,8BAA8B,GAAG,qBAAqB,CAAC;AACpE,MAAM,CAAC,MAAM,8BAA8B,GAAG,qBAAqB,CAAC;AACpE;;;GAGG;AACH,MAAM,CAAC,MAAM,6BAA6B,GAAG,oBAAoB,CAAC;AAClE,iGAAiG;AACjG,MAAM,CAAC,MAAM,mBAAmB,GAAG,UAAU,CAAC;AAE9C;;;;GAIG;AACH,MAAM,CAAC,MAAM,+BAA+B,GAAG,sBAAsB,CAAC;AAEtE;;;GAGG;AACH,MAAM,CAAC,MAAM,0BAA0B,GAAG,iBAAiB,CAAC;AAE5D;;;;GAIG;AACH,MAAM,CAAC,MAAM,uBAAuB,GAAG,cAAc,CAAC;AAEtD;;;GAGG;AACH,MAAM,CAAC,MAAM,iCAAiC,GAAG,wBAAwB,CAAC;AAE1E;;;;GAIG;AACH,MAAM,CAAC,MAAM,2BAA2B,GAAG,+BAA+B,CAAC;AAE3E,4EAA4E;AAC5E,MAAM,UAAU,uBAAuB,CAAC,KAAyB;IAChE,IAAI,CAAC,KAAK;QAAE,OAAO,EAAE,CAAC;IACtB,OAAO,KAAK;SACV,KAAK,CAAC,GAAG,CAAC;SACV,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC;SAC1B,MAAM,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC;AACrC,CAAC;AAED,6EAA6E;AAC7E,MAAM,CAAC,MAAM,8BAA8B,GAAG,qBAAqB,CAAC;AAEpE;;;;;;GAMG;AACH,MAAM,UAAU,6BAA6B,CAC5C,YAA2C,EAC3C,aAAqB;IAErB,IAAI,CAAC,YAAY,EAAE,QAAQ,CAAC,6BAA6B,CAAC,EAAE,CAAC;QAC5D,OAAO,SAAS,CAAC;IAClB,CAAC;IACD,MAAM,IAAI,GAAG,aAAa,CAAC,IAAI,EAAE,IAAI,8BAA8B,CAAC;IACpE,OAAO;QACN,IAAI,EAAE,sBAAsB;QAC5B,EAAE,EAAE,MAAM,CAAC,UAAU,EAAE;QACvB,MAAM,EAAE,oBAAoB;QAC5B,aAAa,EAAE,IAAI;KACnB,CAAC;AACH,CAAC","sourcesContent":["/**\n * The `custom_unsupported` capability gate (plan tasks 13/14).\n *\n * In RPC mode `ctx.ui.custom` cannot render a third-party component — there is no\n * TUI to render it FROM. Historically it returned `undefined` synchronously with\n * NO wire message, so a default RPC client saw nothing at all. A client that\n * can present a native \"this extension UI requires the classic TUI\" notice opts\n * in via a capability flag.\n *\n * This gate is a PURE function so the additive-only guarantee is unit-provable:\n * only a client that advertised the `custom_unsupported` capability gets the\n * additive `extension_ui_request{method:\"custom_unsupported\"}` notice; every\n * other (default) client gets `undefined` — byte-identical to the prior\n * behavior, no extra bytes on the wire.\n */\n\nimport * as crypto from \"node:crypto\";\nimport type { RpcExtensionUIRequest } from \"./rpc-types.ts\";\n\n/** The capability string a client sends in its handshake to opt into the notice. */\nexport const CUSTOM_UNSUPPORTED_CAPABILITY = \"custom_unsupported\";\nexport const EXTENSION_EVENTS_CAPABILITY = \"extension_events\";\nexport const RENDERED_COMPONENTS_CAPABILITY = \"rendered_components\";\nexport const AUTO_TITLE_SESSIONS_CAPABILITY = \"auto_title_sessions\";\n/**\n * Opt-in: the host replaces inline image bytes inside tool results with `image_ref`\n * placeholders for this connection; the client fetches a block on demand with `get_media`.\n */\nexport const MEDIA_PLACEHOLDERS_CAPABILITY = \"media_placeholders\";\n/** Opt-in: the client can present a multi-question `extension_ui_request{method:\"question\"}`. */\nexport const QUESTION_CAPABILITY = \"question\";\n\n/**\n * HOST capability (advertised in `get_protocol_info`, never sent by a client):\n * this host honors `open_session.retain_on_disconnect`, so a session opened with\n * that flag survives its last client's disconnect instead of being closed with it.\n */\nexport const RETAIN_ON_DISCONNECT_CAPABILITY = \"retain_on_disconnect\";\n\n/**\n * HOST capability: this host accepts `open_session.context`, hands it to that session's\n * extensions as `pi.sessionContext`, and republishes it on `list_sessions { include_workers: true }`.\n */\nexport const SESSION_CONTEXT_CAPABILITY = \"session_context\";\n\n/**\n * HOST capability: this host accepts `open_session.kind`, publishes `kind` on every\n * `list_sessions` row, hides `worker` rows unless `include_workers` is set, and keeps a\n * worker session's lifecycle records on the connections attached to it.\n */\nexport const SESSION_KIND_CAPABILITY = \"session_kind\";\n\n/**\n * HOST capability: this host honors `open_session.auto_title`, so a session can opt\n * into or out of engine-side titling independently of `--auto-title-sessions`.\n */\nexport const AUTO_TITLE_PER_SESSION_CAPABILITY = \"auto_title_per_session\";\n\n/**\n * Env var carrying client capabilities to a single-connection stdio RPC host\n * (comma-separated). A launcher may set it from a client handshake; a plain\n * stdio client leaves it unset and sees byte-identical default behavior.\n */\nexport const RPC_CLIENT_CAPABILITIES_ENV = \"SENPI_RPC_CLIENT_CAPABILITIES\";\n\n/** Parse the comma-separated capabilities env value into a trimmed list. */\nexport function parseClientCapabilities(value: string | undefined): string[] {\n\tif (!value) return [];\n\treturn value\n\t\t.split(\",\")\n\t\t.map((part) => part.trim())\n\t\t.filter((part) => part.length > 0);\n}\n\n/** Fallback label when the calling extension's name cannot be determined. */\nexport const DEFAULT_CUSTOM_EXTENSION_LABEL = \"custom UI component\";\n\n/**\n * Build the additive `custom_unsupported` request for a `ctx.ui.custom` call, or\n * `undefined` when the client did not opt in.\n *\n * Returning `undefined` is the load-bearing default-client path: the caller must\n * emit NOTHING in that case, preserving the exact prior wire behavior.\n */\nexport function buildCustomUnsupportedRequest(\n\tcapabilities: readonly string[] | undefined,\n\textensionName: string,\n): RpcExtensionUIRequest | undefined {\n\tif (!capabilities?.includes(CUSTOM_UNSUPPORTED_CAPABILITY)) {\n\t\treturn undefined;\n\t}\n\tconst name = extensionName.trim() || DEFAULT_CUSTOM_EXTENSION_LABEL;\n\treturn {\n\t\ttype: \"extension_ui_request\",\n\t\tid: crypto.randomUUID(),\n\t\tmethod: \"custom_unsupported\",\n\t\textensionName: name,\n\t};\n}\n"]}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { RpcHostMemoryPressureEvent } from "./rpc-types.ts";
|
|
2
|
+
/** Environment override for the RSS warning threshold, in megabytes. */
|
|
3
|
+
export declare const HOST_RSS_WARN_MB_ENV = "SENPI_RPC_HOST_RSS_WARN_MB";
|
|
4
|
+
export declare const DEFAULT_HOST_RSS_WARN_MB = 4096;
|
|
5
|
+
/** Sampling interval. Memory moves slowly; this is bookkeeping, not a hot loop. */
|
|
6
|
+
export declare const HOST_MEMORY_SAMPLE_MS = 30000;
|
|
7
|
+
/** One stderr line per window, however many samples stay above the threshold. */
|
|
8
|
+
export declare const HOST_MEMORY_STDERR_INTERVAL_MS: number;
|
|
9
|
+
export interface HostMemorySamplerOptions {
|
|
10
|
+
/** Delivers one `host_memory_pressure` lifecycle record to every connection. */
|
|
11
|
+
readonly emit: (record: RpcHostMemoryPressureEvent) => void;
|
|
12
|
+
/** Live session count published with the record. */
|
|
13
|
+
readonly sessions: () => number;
|
|
14
|
+
/** Raised while the host is above the threshold; the router halves idle parking. */
|
|
15
|
+
readonly onPressure: (pressure: boolean) => void;
|
|
16
|
+
/** Defaults to one stderr line; tests capture it. */
|
|
17
|
+
readonly log?: (message: string) => void;
|
|
18
|
+
readonly now?: () => number;
|
|
19
|
+
readonly readRssBytes?: () => number;
|
|
20
|
+
readonly env?: Readonly<Record<string, string | undefined>>;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Resident-memory reporter for the shared host.
|
|
24
|
+
*
|
|
25
|
+
* The daemon's capacity is memory, never a refusal: it never declines a session, never
|
|
26
|
+
* kills one, and never counts them against a cap. What it does is SAY how much memory
|
|
27
|
+
* it holds - as a lifecycle record to every connection and one stderr line per five
|
|
28
|
+
* minutes - and, while it is above the threshold, tell the router to park idle sessions
|
|
29
|
+
* at half the usual window so their memory returns to the process sooner.
|
|
30
|
+
*/
|
|
31
|
+
export declare class HostMemorySampler {
|
|
32
|
+
private readonly emit;
|
|
33
|
+
private readonly sessions;
|
|
34
|
+
private readonly onPressure;
|
|
35
|
+
private readonly log;
|
|
36
|
+
private readonly now;
|
|
37
|
+
private readonly readRssBytes;
|
|
38
|
+
private readonly warnMb;
|
|
39
|
+
private timer;
|
|
40
|
+
private pressure;
|
|
41
|
+
private lastLoggedAt;
|
|
42
|
+
constructor(options: HostMemorySamplerOptions);
|
|
43
|
+
start(): void;
|
|
44
|
+
stop(): void;
|
|
45
|
+
/** One sample. Public so tests drive it on an injected clock and RSS reading. */
|
|
46
|
+
sample(): void;
|
|
47
|
+
}
|
|
48
|
+
//# sourceMappingURL=host-memory-sampler.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"host-memory-sampler.d.ts","sourceRoot":"","sources":["../../../src/modes/rpc/host-memory-sampler.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,0BAA0B,EAAE,MAAM,gBAAgB,CAAC;AAEjE,wEAAwE;AACxE,eAAO,MAAM,oBAAoB,+BAA+B,CAAC;AACjE,eAAO,MAAM,wBAAwB,OAAO,CAAC;AAC7C,mFAAmF;AACnF,eAAO,MAAM,qBAAqB,QAAS,CAAC;AAC5C,iFAAiF;AACjF,eAAO,MAAM,8BAA8B,QAAa,CAAC;AAIzD,MAAM,WAAW,wBAAwB;IACxC,gFAAgF;IAChF,QAAQ,CAAC,IAAI,EAAE,CAAC,MAAM,EAAE,0BAA0B,KAAK,IAAI,CAAC;IAC5D,oDAAoD;IACpD,QAAQ,CAAC,QAAQ,EAAE,MAAM,MAAM,CAAC;IAChC,oFAAoF;IACpF,QAAQ,CAAC,UAAU,EAAE,CAAC,QAAQ,EAAE,OAAO,KAAK,IAAI,CAAC;IACjD,qDAAqD;IACrD,QAAQ,CAAC,GAAG,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IACzC,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;IAC5B,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,MAAM,CAAC;IACrC,QAAQ,CAAC,GAAG,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC,CAAC;CAC5D;AAQD;;;;;;;;GAQG;AACH,qBAAa,iBAAiB;IAC7B,OAAO,CAAC,QAAQ,CAAC,IAAI,CAA+C;IACpE,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAe;IACxC,OAAO,CAAC,QAAQ,CAAC,UAAU,CAA8B;IACzD,OAAO,CAAC,QAAQ,CAAC,GAAG,CAA4B;IAChD,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAe;IACnC,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAe;IAC5C,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;IAChC,OAAO,CAAC,KAAK,CAA6C;IAC1D,OAAO,CAAC,QAAQ,CAAS;IACzB,OAAO,CAAC,YAAY,CAAqB;IAEzC,YAAY,OAAO,EAAE,wBAAwB,EAS5C;IAED,KAAK,IAAI,IAAI,CAKZ;IAED,IAAI,IAAI,IAAI,CAIX;IAED,iFAAiF;IACjF,MAAM,IAAI,IAAI,CAkBb;CACD"}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/** Environment override for the RSS warning threshold, in megabytes. */
|
|
2
|
+
export const HOST_RSS_WARN_MB_ENV = "SENPI_RPC_HOST_RSS_WARN_MB";
|
|
3
|
+
export const DEFAULT_HOST_RSS_WARN_MB = 4096;
|
|
4
|
+
/** Sampling interval. Memory moves slowly; this is bookkeeping, not a hot loop. */
|
|
5
|
+
export const HOST_MEMORY_SAMPLE_MS = 30_000;
|
|
6
|
+
/** One stderr line per window, however many samples stay above the threshold. */
|
|
7
|
+
export const HOST_MEMORY_STDERR_INTERVAL_MS = 5 * 60_000;
|
|
8
|
+
const BYTES_PER_MEGABYTE = 1024 * 1024;
|
|
9
|
+
function parsePositiveInteger(value) {
|
|
10
|
+
if (value === undefined || !/^\d+$/.test(value.trim()))
|
|
11
|
+
return undefined;
|
|
12
|
+
const parsed = Number(value.trim());
|
|
13
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Resident-memory reporter for the shared host.
|
|
17
|
+
*
|
|
18
|
+
* The daemon's capacity is memory, never a refusal: it never declines a session, never
|
|
19
|
+
* kills one, and never counts them against a cap. What it does is SAY how much memory
|
|
20
|
+
* it holds - as a lifecycle record to every connection and one stderr line per five
|
|
21
|
+
* minutes - and, while it is above the threshold, tell the router to park idle sessions
|
|
22
|
+
* at half the usual window so their memory returns to the process sooner.
|
|
23
|
+
*/
|
|
24
|
+
export class HostMemorySampler {
|
|
25
|
+
constructor(options) {
|
|
26
|
+
this.pressure = false;
|
|
27
|
+
const env = options.env ?? process.env;
|
|
28
|
+
this.emit = options.emit;
|
|
29
|
+
this.sessions = options.sessions;
|
|
30
|
+
this.onPressure = options.onPressure;
|
|
31
|
+
this.log = options.log ?? ((message) => void process.stderr.write(message));
|
|
32
|
+
this.now = options.now ?? Date.now;
|
|
33
|
+
this.readRssBytes = options.readRssBytes ?? (() => process.memoryUsage.rss());
|
|
34
|
+
this.warnMb = parsePositiveInteger(env[HOST_RSS_WARN_MB_ENV]) ?? DEFAULT_HOST_RSS_WARN_MB;
|
|
35
|
+
}
|
|
36
|
+
start() {
|
|
37
|
+
if (this.timer !== undefined)
|
|
38
|
+
return;
|
|
39
|
+
// Unref'd: memory bookkeeping must never be the reason the host stays alive.
|
|
40
|
+
this.timer = setInterval(() => this.sample(), HOST_MEMORY_SAMPLE_MS);
|
|
41
|
+
this.timer.unref?.();
|
|
42
|
+
}
|
|
43
|
+
stop() {
|
|
44
|
+
if (this.timer === undefined)
|
|
45
|
+
return;
|
|
46
|
+
clearInterval(this.timer);
|
|
47
|
+
this.timer = undefined;
|
|
48
|
+
}
|
|
49
|
+
/** One sample. Public so tests drive it on an injected clock and RSS reading. */
|
|
50
|
+
sample() {
|
|
51
|
+
const rssMb = Math.round(this.readRssBytes() / BYTES_PER_MEGABYTE);
|
|
52
|
+
if (rssMb <= this.warnMb) {
|
|
53
|
+
if (!this.pressure)
|
|
54
|
+
return;
|
|
55
|
+
this.pressure = false;
|
|
56
|
+
this.onPressure(false);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (!this.pressure) {
|
|
60
|
+
this.pressure = true;
|
|
61
|
+
this.onPressure(true);
|
|
62
|
+
}
|
|
63
|
+
const sessions = this.sessions();
|
|
64
|
+
this.emit({ type: "host_memory_pressure", rssMb, sessions });
|
|
65
|
+
const now = this.now();
|
|
66
|
+
if (this.lastLoggedAt !== undefined && now - this.lastLoggedAt < HOST_MEMORY_STDERR_INTERVAL_MS)
|
|
67
|
+
return;
|
|
68
|
+
this.lastLoggedAt = now;
|
|
69
|
+
this.log(`senpi rpc host memory pressure: rssMb=${rssMb} sessions=${sessions} (idle parking halved)\n`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
//# sourceMappingURL=host-memory-sampler.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"host-memory-sampler.js","sourceRoot":"","sources":["../../../src/modes/rpc/host-memory-sampler.ts"],"names":[],"mappings":"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","sourcesContent":["import type { RpcHostMemoryPressureEvent } from \"./rpc-types.ts\";\n\n/** Environment override for the RSS warning threshold, in megabytes. */\nexport const HOST_RSS_WARN_MB_ENV = \"SENPI_RPC_HOST_RSS_WARN_MB\";\nexport const DEFAULT_HOST_RSS_WARN_MB = 4096;\n/** Sampling interval. Memory moves slowly; this is bookkeeping, not a hot loop. */\nexport const HOST_MEMORY_SAMPLE_MS = 30_000;\n/** One stderr line per window, however many samples stay above the threshold. */\nexport const HOST_MEMORY_STDERR_INTERVAL_MS = 5 * 60_000;\n\nconst BYTES_PER_MEGABYTE = 1024 * 1024;\n\nexport interface HostMemorySamplerOptions {\n\t/** Delivers one `host_memory_pressure` lifecycle record to every connection. */\n\treadonly emit: (record: RpcHostMemoryPressureEvent) => void;\n\t/** Live session count published with the record. */\n\treadonly sessions: () => number;\n\t/** Raised while the host is above the threshold; the router halves idle parking. */\n\treadonly onPressure: (pressure: boolean) => void;\n\t/** Defaults to one stderr line; tests capture it. */\n\treadonly log?: (message: string) => void;\n\treadonly now?: () => number;\n\treadonly readRssBytes?: () => number;\n\treadonly env?: Readonly<Record<string, string | undefined>>;\n}\n\nfunction parsePositiveInteger(value: string | undefined): number | undefined {\n\tif (value === undefined || !/^\\d+$/.test(value.trim())) return undefined;\n\tconst parsed = Number(value.trim());\n\treturn Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;\n}\n\n/**\n * Resident-memory reporter for the shared host.\n *\n * The daemon's capacity is memory, never a refusal: it never declines a session, never\n * kills one, and never counts them against a cap. What it does is SAY how much memory\n * it holds - as a lifecycle record to every connection and one stderr line per five\n * minutes - and, while it is above the threshold, tell the router to park idle sessions\n * at half the usual window so their memory returns to the process sooner.\n */\nexport class HostMemorySampler {\n\tprivate readonly emit: (record: RpcHostMemoryPressureEvent) => void;\n\tprivate readonly sessions: () => number;\n\tprivate readonly onPressure: (pressure: boolean) => void;\n\tprivate readonly log: (message: string) => void;\n\tprivate readonly now: () => number;\n\tprivate readonly readRssBytes: () => number;\n\tprivate readonly warnMb: number;\n\tprivate timer: ReturnType<typeof setInterval> | undefined;\n\tprivate pressure = false;\n\tprivate lastLoggedAt: number | undefined;\n\n\tconstructor(options: HostMemorySamplerOptions) {\n\t\tconst env = options.env ?? process.env;\n\t\tthis.emit = options.emit;\n\t\tthis.sessions = options.sessions;\n\t\tthis.onPressure = options.onPressure;\n\t\tthis.log = options.log ?? ((message) => void process.stderr.write(message));\n\t\tthis.now = options.now ?? Date.now;\n\t\tthis.readRssBytes = options.readRssBytes ?? (() => process.memoryUsage.rss());\n\t\tthis.warnMb = parsePositiveInteger(env[HOST_RSS_WARN_MB_ENV]) ?? DEFAULT_HOST_RSS_WARN_MB;\n\t}\n\n\tstart(): void {\n\t\tif (this.timer !== undefined) return;\n\t\t// Unref'd: memory bookkeeping must never be the reason the host stays alive.\n\t\tthis.timer = setInterval(() => this.sample(), HOST_MEMORY_SAMPLE_MS);\n\t\tthis.timer.unref?.();\n\t}\n\n\tstop(): void {\n\t\tif (this.timer === undefined) return;\n\t\tclearInterval(this.timer);\n\t\tthis.timer = undefined;\n\t}\n\n\t/** One sample. Public so tests drive it on an injected clock and RSS reading. */\n\tsample(): void {\n\t\tconst rssMb = Math.round(this.readRssBytes() / BYTES_PER_MEGABYTE);\n\t\tif (rssMb <= this.warnMb) {\n\t\t\tif (!this.pressure) return;\n\t\t\tthis.pressure = false;\n\t\t\tthis.onPressure(false);\n\t\t\treturn;\n\t\t}\n\t\tif (!this.pressure) {\n\t\t\tthis.pressure = true;\n\t\t\tthis.onPressure(true);\n\t\t}\n\t\tconst sessions = this.sessions();\n\t\tthis.emit({ type: \"host_memory_pressure\", rssMb, sessions });\n\t\tconst now = this.now();\n\t\tif (this.lastLoggedAt !== undefined && now - this.lastLoggedAt < HOST_MEMORY_STDERR_INTERVAL_MS) return;\n\t\tthis.lastLoggedAt = now;\n\t\tthis.log(`senpi rpc host memory pressure: rssMb=${rssMb} sessions=${sessions} (idle parking halved)\\n`);\n\t}\n}\n"]}
|