mixdog 0.9.53 → 0.9.55
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -1
- package/scripts/agent-model-liveness-test.mjs +68 -0
- package/scripts/anthropic-transport-policy-test.mjs +260 -0
- package/scripts/desktop-session-bridge-test.mjs +47 -0
- package/scripts/gemini-provider-test.mjs +396 -1
- package/scripts/grok-oauth-refresh-race-test.mjs +76 -1
- package/scripts/interrupted-turn-history-test.mjs +28 -0
- package/scripts/openai-oauth-ws-1006-retry-test.mjs +81 -1
- package/scripts/pending-completion-drop-test.mjs +160 -4
- package/scripts/pending-messages-lock-nonblocking-test.mjs +62 -0
- package/scripts/process-lifecycle-test.mjs +18 -4
- package/scripts/prompt-input-parity-test.mjs +145 -0
- package/scripts/provider-admission-scheduler-test.mjs +4 -5
- package/scripts/provider-contract-test.mjs +91 -33
- package/scripts/provider-toolcall-test.mjs +97 -17
- package/scripts/streaming-tail-window-test.mjs +146 -0
- package/scripts/tui-runtime-load-bench-entry.jsx +608 -0
- package/scripts/tui-runtime-load-bench.mjs +45 -0
- package/scripts/tui-store-frame-batch-test.mjs +99 -0
- package/scripts/tui-transcript-perf-test.mjs +367 -2
- package/scripts/write-backpressure-test.mjs +147 -0
- package/src/cli.mjs +5 -5
- package/src/lib/keychain-cjs.cjs +114 -25
- package/src/repl.mjs +11 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +62 -25
- package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +8 -2
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +50 -23
- package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +15 -4
- package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +136 -27
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +104 -20
- package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +96 -75
- package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +40 -1
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +5 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +35 -4
- package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +3 -1
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +49 -9
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +14 -2
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +9 -4
- package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +53 -13
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +145 -23
- package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +20 -4
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +316 -63
- package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +23 -1
- package/src/runtime/agent/orchestrator/session/store.mjs +46 -1
- package/src/runtime/agent/orchestrator/stall-policy.mjs +6 -13
- package/src/runtime/memory/lib/trace-store.mjs +66 -4
- package/src/runtime/shared/buffered-appender.mjs +83 -4
- package/src/runtime/shared/memory-snapshot.mjs +45 -36
- package/src/runtime/shared/process-lifecycle.mjs +166 -45
- package/src/runtime/shared/process-shutdown.mjs +2 -1
- package/src/session-runtime/model-route-api.mjs +4 -1
- package/src/session-runtime/native-search.mjs +4 -2
- package/src/session-runtime/provider-auth-api.mjs +8 -1
- package/src/session-runtime/provider-models.mjs +8 -3
- package/src/session-runtime/resource-api.mjs +2 -1
- package/src/session-runtime/runtime-core.mjs +32 -0
- package/src/session-runtime/session-turn-api.mjs +1 -0
- package/src/session-runtime/warmup-schedulers.mjs +5 -1
- package/src/standalone/agent-tool.mjs +19 -1
- package/src/tui/App.jsx +10 -4
- package/src/tui/app/text-layout.mjs +9 -1
- package/src/tui/app/transcript-window.mjs +146 -60
- package/src/tui/app/use-mouse-input.mjs +16 -8
- package/src/tui/app/use-transcript-scroll.mjs +71 -19
- package/src/tui/app/use-transcript-window.mjs +21 -10
- package/src/tui/components/PromptInput.jsx +13 -6
- package/src/tui/dist/index.mjs +1094 -232
- package/src/tui/engine/context-state.mjs +31 -31
- package/src/tui/engine/frame-batched-store.mjs +75 -0
- package/src/tui/engine/session-api-ext.mjs +6 -1
- package/src/tui/engine/session-api.mjs +9 -3
- package/src/tui/engine/session-flow.mjs +54 -27
- package/src/tui/engine/turn.mjs +44 -3
- package/src/tui/engine.mjs +515 -36
- package/src/tui/input-editing.mjs +33 -13
- package/src/tui/markdown/measure-rendered-rows.mjs +117 -5
- package/vendor/ink/build/ink.js +8 -16
|
@@ -246,21 +246,14 @@ export const PROVIDER_WS_FIRST_MEANINGFUL_TIMEOUT_MS = resolveTimeoutMs(
|
|
|
246
246
|
{ minMs: 10_000, maxMs: STALL_WARN_MS },
|
|
247
247
|
);
|
|
248
248
|
|
|
249
|
-
// WS
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
//
|
|
253
|
-
//
|
|
254
|
-
// the TUI lead-turn watchdog is also 300s. If the provider waits ~285-300s
|
|
255
|
-
// before surfacing the stall, the UI watchdog often wins and the user only sees
|
|
256
|
-
// "Turn timed out after 300s" instead of a provider retry/fallback.
|
|
257
|
-
//
|
|
258
|
-
// Keep WS hangs bounded well below the TUI watchdog while still allowing normal
|
|
259
|
-
// long reasoning streams that emit reasoning/text/tool deltas (those reset this
|
|
260
|
-
// timer). Env-overridable for experiments.
|
|
249
|
+
// WS semantic idle uses the same default ceiling as OpenAI HTTP/SSE semantic
|
|
250
|
+
// idle. This is semantic progress only (not the WS inter-chunk byte timer), so
|
|
251
|
+
// reasoning/text/tool deltas reset it while metadata cannot. Keep the default
|
|
252
|
+
// at PROVIDER_MAX_BEFORE_WARN_MS (~285s), strictly below the 300s worker
|
|
253
|
+
// watchdog, rather than rounding it to 300s.
|
|
261
254
|
export const PROVIDER_WS_SEMANTIC_IDLE_TIMEOUT_MS = resolveTimeoutMs(
|
|
262
255
|
['MIXDOG_PROVIDER_WS_SEMANTIC_IDLE_TIMEOUT_MS', 'MIXDOG_PROVIDER_WS_OUTPUT_IDLE_TIMEOUT_MS'],
|
|
263
|
-
|
|
256
|
+
PROVIDER_MAX_BEFORE_WARN_MS,
|
|
264
257
|
{ minMs: 10_000, maxMs: PROVIDER_MAX_BEFORE_WARN_MS },
|
|
265
258
|
);
|
|
266
259
|
|
|
@@ -658,6 +658,8 @@ const TRACE_COLS = [
|
|
|
658
658
|
'cached_tokens', 'cache_write_tokens', 'duration_ms',
|
|
659
659
|
'error_message', 'payload', 'parent_span_id', 'entry_id',
|
|
660
660
|
]
|
|
661
|
+
const PG_MAX_BIND_PARAMETERS = 65_535
|
|
662
|
+
const TRACE_INSERT_MAX_ROWS = Math.floor(PG_MAX_BIND_PARAMETERS / TRACE_COLS.length)
|
|
661
663
|
|
|
662
664
|
// ---------------------------------------------------------------------------
|
|
663
665
|
// Cross-request trace_events write queue (100ms / 500-row flush window)
|
|
@@ -665,6 +667,9 @@ const TRACE_COLS = [
|
|
|
665
667
|
|
|
666
668
|
const TRACE_QUEUE_FLUSH_MS = 100
|
|
667
669
|
const TRACE_QUEUE_MAX_ROWS = 500
|
|
670
|
+
// Twenty normal flush batches leaves ample room for ordinary DB jitter while
|
|
671
|
+
// bounding producer memory across repeated flush failures.
|
|
672
|
+
export const TRACE_QUEUE_MAX_PENDING_EVENTS = 10_000
|
|
668
673
|
|
|
669
674
|
// Per-db queue state (keyed by db object identity via WeakMap).
|
|
670
675
|
const _traceQueues = new WeakMap()
|
|
@@ -672,12 +677,54 @@ const _traceQueues = new WeakMap()
|
|
|
672
677
|
function _getQueue(db) {
|
|
673
678
|
let q = _traceQueues.get(db)
|
|
674
679
|
if (!q) {
|
|
675
|
-
q = { pending: [], timer: null, flushPromise: null }
|
|
680
|
+
q = { pending: [], timer: null, flushPromise: null, droppedEvents: 0 }
|
|
676
681
|
_traceQueues.set(db, q)
|
|
677
682
|
}
|
|
678
683
|
return q
|
|
679
684
|
}
|
|
680
685
|
|
|
686
|
+
function _pendingEventCount(q) {
|
|
687
|
+
return q.pending.reduce((n, w) => n + w.events.length, 0)
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function _trimPendingQueue(q) {
|
|
691
|
+
let overflow = _pendingEventCount(q) - TRACE_QUEUE_MAX_PENDING_EVENTS
|
|
692
|
+
while (overflow > 0 && q.pending.length > 0) {
|
|
693
|
+
const oldest = q.pending[0]
|
|
694
|
+
if (oldest.events.length <= overflow) {
|
|
695
|
+
q.pending.shift()
|
|
696
|
+
q.droppedEvents += oldest.events.length
|
|
697
|
+
overflow -= oldest.events.length
|
|
698
|
+
} else {
|
|
699
|
+
oldest.events.splice(0, overflow)
|
|
700
|
+
q.droppedEvents += overflow
|
|
701
|
+
overflow = 0
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function _dropWrittenEvents(wrappers, count) {
|
|
707
|
+
while (count > 0 && wrappers.length > 0) {
|
|
708
|
+
const wrapper = wrappers[0]
|
|
709
|
+
if (wrapper.events.length <= count) {
|
|
710
|
+
wrappers.shift()
|
|
711
|
+
count -= wrapper.events.length
|
|
712
|
+
} else {
|
|
713
|
+
wrapper.events.splice(0, count)
|
|
714
|
+
count = 0
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/** Observable queue depth and overflow count for a trace database. */
|
|
720
|
+
export function getTraceQueueStats(db) {
|
|
721
|
+
const q = _traceQueues.get(db)
|
|
722
|
+
return {
|
|
723
|
+
pendingEvents: q ? _pendingEventCount(q) : 0,
|
|
724
|
+
droppedEvents: q?.droppedEvents ?? 0,
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
681
728
|
async function _flushQueue(db, q) {
|
|
682
729
|
// When a flush is already running, callers reuse its promise — but enqueues
|
|
683
730
|
// that arrive during that flush schedule a fresh timer that fires here and
|
|
@@ -691,10 +738,12 @@ async function _flushQueue(db, q) {
|
|
|
691
738
|
if (wrappers.length === 0) return { inserted: 0 }
|
|
692
739
|
const MAX_RETRIES = 3
|
|
693
740
|
q.flushPromise = (async () => {
|
|
741
|
+
let writtenEvents = 0
|
|
694
742
|
try {
|
|
695
743
|
const allEvents = wrappers.flatMap(w => w.events)
|
|
696
|
-
return await
|
|
744
|
+
return await _insertTraceEventBatches(db, allEvents, count => { writtenEvents += count })
|
|
697
745
|
} catch (err) {
|
|
746
|
+
_dropWrittenEvents(wrappers, writtenEvents)
|
|
698
747
|
for (const w of wrappers) w.attempts += 1
|
|
699
748
|
const keep = wrappers.filter(w => w.attempts < MAX_RETRIES)
|
|
700
749
|
const dropped = wrappers.filter(w => w.attempts >= MAX_RETRIES)
|
|
@@ -702,6 +751,7 @@ async function _flushQueue(db, q) {
|
|
|
702
751
|
__mixdogMemoryLog(`[trace-queue] dropped ${dropped.reduce((n, w) => n + w.events.length, 0)} events after ${MAX_RETRIES} retries: ${err?.message}\n`)
|
|
703
752
|
}
|
|
704
753
|
q.pending.unshift(...keep)
|
|
754
|
+
_trimPendingQueue(q)
|
|
705
755
|
__mixdogMemoryLog(`[trace-queue] flush error: ${err?.message}\n`)
|
|
706
756
|
throw err
|
|
707
757
|
} finally {
|
|
@@ -737,7 +787,7 @@ async function drainTraceQueue(db) {
|
|
|
737
787
|
try {
|
|
738
788
|
if (q.timer) { clearTimeout(q.timer); q.timer = null }
|
|
739
789
|
if (q.flushPromise) await q.flushPromise.catch(() => {})
|
|
740
|
-
if (q.pending.length > 0) await
|
|
790
|
+
if (q.pending.length > 0) await _insertTraceEventBatches(db, q.pending.splice(0).flatMap(w => w.events))
|
|
741
791
|
} catch (e) {
|
|
742
792
|
__mixdogMemoryLog(`[trace-queue] drain failed: ${e?.message}\n`)
|
|
743
793
|
}
|
|
@@ -813,7 +863,8 @@ export function enqueueTraceEvents(db, events) {
|
|
|
813
863
|
q.pending.push({ events: [...events], attempts: 0 })
|
|
814
864
|
// Row cap counts pending EVENTS, not wrappers — a single wrapper with
|
|
815
865
|
// >TRACE_QUEUE_MAX_ROWS events would otherwise sit until the timer.
|
|
816
|
-
|
|
866
|
+
_trimPendingQueue(q)
|
|
867
|
+
const pendingEvents = _pendingEventCount(q)
|
|
817
868
|
if (pendingEvents >= TRACE_QUEUE_MAX_ROWS) {
|
|
818
869
|
// Flush immediately when row cap reached — don't wait for timer.
|
|
819
870
|
if (q.timer) { clearTimeout(q.timer); q.timer = null }
|
|
@@ -831,6 +882,17 @@ async function _insertTraceEventsDirect(db, events) {
|
|
|
831
882
|
return insertTraceEvents(db, events)
|
|
832
883
|
}
|
|
833
884
|
|
|
885
|
+
async function _insertTraceEventBatches(db, events, onBatchInserted) {
|
|
886
|
+
let inserted = 0
|
|
887
|
+
for (let start = 0; start < events.length; start += TRACE_INSERT_MAX_ROWS) {
|
|
888
|
+
const batch = events.slice(start, start + TRACE_INSERT_MAX_ROWS)
|
|
889
|
+
const result = await _insertTraceEventsDirect(db, batch)
|
|
890
|
+
inserted += result.inserted
|
|
891
|
+
onBatchInserted?.(batch.length)
|
|
892
|
+
}
|
|
893
|
+
return { inserted }
|
|
894
|
+
}
|
|
895
|
+
|
|
834
896
|
export async function insertTraceEvents(db, events) {
|
|
835
897
|
if (!Array.isArray(events) || events.length === 0) return { inserted: 0 }
|
|
836
898
|
|
|
@@ -16,14 +16,22 @@ import { appendFile } from 'node:fs/promises';
|
|
|
16
16
|
|
|
17
17
|
const BUFFER_FLUSH_BYTES = 32 * 1024;
|
|
18
18
|
const DEBOUNCE_MS = 50;
|
|
19
|
+
// Per path: comfortably above normal transcript/log bursts while bounding
|
|
20
|
+
// producer memory when a filesystem append is stalled.
|
|
21
|
+
export const BUFFERED_APPEND_MAX_BYTES = 8 * 1024 * 1024;
|
|
22
|
+
let totalDroppedBufferedBytes = 0;
|
|
23
|
+
const droppedBytesByPath = new Map();
|
|
19
24
|
|
|
20
|
-
// path -> { chunks: string[], bytes: number, timer, flushing, inFlight, failCount }
|
|
25
|
+
// path -> { chunks: string[], bytes: number, timer, flushing, inFlight, failCount, droppedBytes }
|
|
21
26
|
const queues = new Map();
|
|
22
27
|
|
|
23
28
|
function getQueue(path) {
|
|
24
29
|
let q = queues.get(path);
|
|
25
30
|
if (!q) {
|
|
26
|
-
q = {
|
|
31
|
+
q = {
|
|
32
|
+
chunks: [], bytes: 0, timer: null, flushing: false, inFlight: null,
|
|
33
|
+
failCount: 0, droppedBytes: droppedBytesByPath.get(path) ?? 0, path,
|
|
34
|
+
};
|
|
27
35
|
queues.set(path, q);
|
|
28
36
|
}
|
|
29
37
|
return q;
|
|
@@ -45,6 +53,63 @@ function deleteIfIdle(path, q) {
|
|
|
45
53
|
}
|
|
46
54
|
}
|
|
47
55
|
|
|
56
|
+
function recordDroppedBytes(q, bytes) {
|
|
57
|
+
q.droppedBytes += bytes;
|
|
58
|
+
totalDroppedBufferedBytes += bytes;
|
|
59
|
+
droppedBytesByPath.set(q.path, q.droppedBytes);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function keepNewestUtf8(text, maxBytes, totalBytes) {
|
|
63
|
+
// At most maxBytes UTF-16 code units are encoded (plus one code unit when
|
|
64
|
+
// the slice would split a surrogate pair), bounding this work even when a
|
|
65
|
+
// producer supplies an arbitrarily large string.
|
|
66
|
+
let windowStart = Math.max(0, text.length - maxBytes);
|
|
67
|
+
if (windowStart > 0
|
|
68
|
+
&& /[\uDC00-\uDFFF]/.test(text[windowStart])
|
|
69
|
+
&& /[\uD800-\uDBFF]/.test(text[windowStart - 1])) {
|
|
70
|
+
windowStart -= 1;
|
|
71
|
+
}
|
|
72
|
+
const bytes = Buffer.from(text.slice(windowStart), 'utf8');
|
|
73
|
+
let start = Math.max(0, bytes.length - maxBytes);
|
|
74
|
+
while (start < bytes.length && (bytes[start] & 0xC0) === 0x80) start += 1;
|
|
75
|
+
return {
|
|
76
|
+
text: bytes.subarray(start).toString('utf8'),
|
|
77
|
+
bytes: bytes.length - start,
|
|
78
|
+
droppedBytes: totalBytes - (bytes.length - start),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function capIncomingText(text) {
|
|
83
|
+
// A single native byteLength pass keeps queue accounting and flush timing
|
|
84
|
+
// exact; only oversized input uses bounded tail-window encoding below.
|
|
85
|
+
const bytes = Buffer.byteLength(text, 'utf8');
|
|
86
|
+
if (bytes <= BUFFERED_APPEND_MAX_BYTES) return { text, bytes, droppedBytes: 0 };
|
|
87
|
+
return keepNewestUtf8(text, BUFFERED_APPEND_MAX_BYTES, bytes);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function trimBufferedQueue(q) {
|
|
91
|
+
while (q.bytes > BUFFERED_APPEND_MAX_BYTES && q.chunks.length > 0) {
|
|
92
|
+
const oldest = q.chunks[0];
|
|
93
|
+
const oldestBytes = Buffer.byteLength(oldest, 'utf8');
|
|
94
|
+
const overflowBytes = q.bytes - BUFFERED_APPEND_MAX_BYTES;
|
|
95
|
+
if (oldestBytes <= overflowBytes) {
|
|
96
|
+
q.chunks.shift();
|
|
97
|
+
q.bytes -= oldestBytes;
|
|
98
|
+
recordDroppedBytes(q, oldestBytes);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const retained = keepNewestUtf8(oldest, oldestBytes - overflowBytes, oldestBytes);
|
|
103
|
+
const removedBytes = oldestBytes - retained.bytes;
|
|
104
|
+
q.chunks[0] = retained.text;
|
|
105
|
+
q.bytes -= removedBytes;
|
|
106
|
+
recordDroppedBytes(q, removedBytes);
|
|
107
|
+
// A character-boundary trim can remove up to three extra bytes rather
|
|
108
|
+
// than split a code point or exceed the hard byte cap.
|
|
109
|
+
if (q.bytes > BUFFERED_APPEND_MAX_BYTES) continue;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
48
113
|
function _flush(path, q) {
|
|
49
114
|
if (q.flushing) return;
|
|
50
115
|
if (q.chunks.length === 0) return;
|
|
@@ -69,6 +134,7 @@ function _flush(path, q) {
|
|
|
69
134
|
if (q.failCount <= 3) {
|
|
70
135
|
q.chunks.unshift(data);
|
|
71
136
|
q.bytes += Buffer.byteLength(data, 'utf8');
|
|
137
|
+
trimBufferedQueue(q);
|
|
72
138
|
}
|
|
73
139
|
})
|
|
74
140
|
.finally(() => {
|
|
@@ -89,8 +155,11 @@ function _flush(path, q) {
|
|
|
89
155
|
export function appendBuffered(path, text) {
|
|
90
156
|
if (!path || !text) return;
|
|
91
157
|
const q = getQueue(path);
|
|
92
|
-
|
|
93
|
-
q.
|
|
158
|
+
const incoming = capIncomingText(text);
|
|
159
|
+
q.chunks.push(incoming.text);
|
|
160
|
+
q.bytes += incoming.bytes;
|
|
161
|
+
if (incoming.droppedBytes > 0) recordDroppedBytes(q, incoming.droppedBytes);
|
|
162
|
+
trimBufferedQueue(q);
|
|
94
163
|
if (q.bytes >= BUFFER_FLUSH_BYTES) {
|
|
95
164
|
if (q.timer) { clearTimeout(q.timer); q.timer = null; }
|
|
96
165
|
_flush(path, q);
|
|
@@ -99,6 +168,16 @@ export function appendBuffered(path, text) {
|
|
|
99
168
|
}
|
|
100
169
|
}
|
|
101
170
|
|
|
171
|
+
/** Observable overflow counters for buffered append queues. */
|
|
172
|
+
export function getBufferedAppenderStats(path) {
|
|
173
|
+
const q = queues.get(path);
|
|
174
|
+
return {
|
|
175
|
+
bufferedBytes: q?.bytes ?? 0,
|
|
176
|
+
droppedBytes: q?.droppedBytes ?? droppedBytesByPath.get(path) ?? 0,
|
|
177
|
+
totalDroppedBytes: totalDroppedBufferedBytes,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
102
181
|
/**
|
|
103
182
|
* Synchronously drain any remaining buffered bytes for every known path.
|
|
104
183
|
* Intended for process exit hooks only — best-effort, never throws.
|
|
@@ -1,28 +1,27 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
2
|
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
unlinkSync,
|
|
11
|
-
writeFileSync,
|
|
12
|
-
writeSync,
|
|
13
|
-
} from 'node:fs';
|
|
3
|
+
mkdir,
|
|
4
|
+
open,
|
|
5
|
+
rename,
|
|
6
|
+
stat,
|
|
7
|
+
unlink,
|
|
8
|
+
writeFile,
|
|
9
|
+
} from 'node:fs/promises';
|
|
14
10
|
import { freemem, totalmem } from 'node:os';
|
|
15
11
|
import { join } from 'node:path';
|
|
16
12
|
import { promisify } from 'node:util';
|
|
17
|
-
import {
|
|
13
|
+
import { withFileLock } from './atomic-file.mjs';
|
|
18
14
|
import { resolvePluginData } from './plugin-paths.mjs';
|
|
19
15
|
|
|
20
16
|
const execFileAsync = promisify(execFile);
|
|
21
17
|
export const MEMORY_PRESSURE_SNAPSHOT_MAX_BYTES = 64 * 1024;
|
|
22
18
|
const SNAPSHOT_NAME = 'memory-pressure.jsonl';
|
|
23
19
|
const SNAPSHOT_INTERVAL_MS = 10 * 60 * 1000;
|
|
20
|
+
const SNAPSHOT_INTERVAL_JITTER_MS = 60 * 1000;
|
|
21
|
+
const PERIODIC_SNAPSHOT_INTERVAL_MS = SNAPSHOT_INTERVAL_MS
|
|
22
|
+
+ Math.floor(Math.random() * ((2 * SNAPSHOT_INTERVAL_JITTER_MS) + 1))
|
|
23
|
+
- SNAPSHOT_INTERVAL_JITTER_MS;
|
|
24
24
|
const SNAPSHOT_RATE_LIMIT_MS = 60 * 1000;
|
|
25
|
-
const SNAPSHOT_LOCK_TIMEOUT_MS = 2000;
|
|
26
25
|
const SNAPSHOT_LOCK_STALE_MS = 10000;
|
|
27
26
|
|
|
28
27
|
let periodicTimer = null;
|
|
@@ -59,42 +58,50 @@ function systemMemory() {
|
|
|
59
58
|
}
|
|
60
59
|
}
|
|
61
60
|
|
|
62
|
-
function
|
|
63
|
-
|
|
64
|
-
|
|
61
|
+
async function pathExists(path) {
|
|
62
|
+
try {
|
|
63
|
+
await stat(path);
|
|
64
|
+
return true;
|
|
65
|
+
} catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function rotateSnapshot(paths) {
|
|
71
|
+
const size = Number((await stat(paths.snapshot)).size) || 0;
|
|
72
|
+
try { await unlink(paths.previousSnapshot); } catch {}
|
|
65
73
|
if (size <= MEMORY_PRESSURE_SNAPSHOT_MAX_BYTES) {
|
|
66
|
-
|
|
74
|
+
await rename(paths.snapshot, paths.previousSnapshot);
|
|
67
75
|
return;
|
|
68
76
|
}
|
|
69
|
-
|
|
70
|
-
|
|
77
|
+
await writeFile(paths.previousSnapshot, '', { mode: 0o600 });
|
|
78
|
+
await unlink(paths.snapshot);
|
|
71
79
|
}
|
|
72
80
|
|
|
73
|
-
function appendSnapshot(entry) {
|
|
81
|
+
async function appendSnapshot(entry) {
|
|
74
82
|
const line = `${JSON.stringify(entry)}\n`;
|
|
75
83
|
if (Buffer.byteLength(line) > MEMORY_PRESSURE_SNAPSHOT_MAX_BYTES) return false;
|
|
76
84
|
const paths = snapshotPaths();
|
|
77
85
|
try {
|
|
78
|
-
|
|
79
|
-
return
|
|
80
|
-
if (
|
|
81
|
-
&&
|
|
82
|
-
|
|
86
|
+
await mkdir(paths.directory, { recursive: true, mode: 0o700 });
|
|
87
|
+
return await withFileLock(paths.lock, async () => {
|
|
88
|
+
if (await pathExists(paths.previousSnapshot)
|
|
89
|
+
&& (await stat(paths.previousSnapshot)).size > MEMORY_PRESSURE_SNAPSHOT_MAX_BYTES) {
|
|
90
|
+
await writeFile(paths.previousSnapshot, '', { mode: 0o600 });
|
|
83
91
|
}
|
|
84
|
-
if (
|
|
85
|
-
&&
|
|
86
|
-
rotateSnapshot(paths);
|
|
92
|
+
if (await pathExists(paths.snapshot)
|
|
93
|
+
&& (await stat(paths.snapshot)).size + Buffer.byteLength(line) > MEMORY_PRESSURE_SNAPSHOT_MAX_BYTES) {
|
|
94
|
+
await rotateSnapshot(paths);
|
|
87
95
|
}
|
|
88
|
-
const fd =
|
|
96
|
+
const fd = await open(paths.snapshot, 'a', 0o600);
|
|
89
97
|
try {
|
|
90
|
-
|
|
91
|
-
fsyncSync(fd);
|
|
98
|
+
await fd.write(line, null, 'utf8');
|
|
92
99
|
} finally {
|
|
93
|
-
|
|
100
|
+
await fd.close();
|
|
94
101
|
}
|
|
95
|
-
return
|
|
102
|
+
return (await stat(paths.snapshot)).size <= MEMORY_PRESSURE_SNAPSHOT_MAX_BYTES;
|
|
96
103
|
}, {
|
|
97
|
-
timeoutMs:
|
|
104
|
+
timeoutMs: 0,
|
|
98
105
|
staleMs: SNAPSHOT_LOCK_STALE_MS,
|
|
99
106
|
});
|
|
100
107
|
} catch {
|
|
@@ -197,7 +204,7 @@ export async function captureMemoryPressureSnapshot(reason = 'memory-pressure')
|
|
|
197
204
|
return appendSnapshot(entry);
|
|
198
205
|
}
|
|
199
206
|
|
|
200
|
-
function capturePeriodicMemorySnapshot() {
|
|
207
|
+
async function capturePeriodicMemorySnapshot() {
|
|
201
208
|
if (!enabled()) return false;
|
|
202
209
|
return appendSnapshot(baseSnapshot('periodic'));
|
|
203
210
|
}
|
|
@@ -224,7 +231,9 @@ export function requestMemoryPressureSnapshot(reason) {
|
|
|
224
231
|
|
|
225
232
|
export function armMemoryPressureSampling() {
|
|
226
233
|
if (!enabled() || periodicTimer) return false;
|
|
227
|
-
periodicTimer = setInterval(() => {
|
|
234
|
+
periodicTimer = setInterval(() => {
|
|
235
|
+
void capturePeriodicMemorySnapshot().catch(() => {});
|
|
236
|
+
}, PERIODIC_SNAPSHOT_INTERVAL_MS);
|
|
228
237
|
periodicTimer.unref?.();
|
|
229
238
|
return true;
|
|
230
239
|
}
|