Files
cc-web/scripts/runtime-state-memory-unit.js

239 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const crypto = require('node:crypto');
const { spawnSync } = require('node:child_process');
const { createSessionHistoryStore } = require('../lib/session-history-store');
const ID = 'synthetic-runtime-memory';
const TARGET_BYTES = 80 * 1024 * 1024;
const BLOCK_BYTES = 64 * 1024;
const LARGE_PIECE = 'a'.repeat(32 * 1024 - 16) + '\\引号"\n';
const ASCII_PIECE = 'b'.repeat(32 * 1024 - 16) + '\\quote"\n';
const SPECIAL = JSON.parse('{"text":"中文🙂\\n\\\\\\\"","__proto__":{"own":"保留值"},"constructor":"普通属性"}');
const startedAt = Date.now();
function stage(message) {
process.stderr.write(`[运行态内存回归 +${((Date.now() - startedAt) / 1000).toFixed(2)} 秒] ${message}\n`);
}
function temporaryRoot() {
// 此项测编码和解析的内存峰值;慢盘fsync会耗尽60秒预算,优先真实tmpfs。
if (process.platform === 'linux' && typeof fs.statfsSync === 'function') {
try {
const stat = fs.statfsSync('/dev/shm');
fs.accessSync('/dev/shm', fs.constants.W_OK);
if (Number(stat.bavail) * Number(stat.bsize) >= 512 * 1024 * 1024) return '/dev/shm';
} catch {}
}
return os.tmpdir();
}
function textHash(value) {
const hash = crypto.createHash('sha256');
for (let offset = 0; offset < value.length; offset += 32 * 1024) hash.update(value.slice(offset, offset + 32 * 1024));
return hash.digest('hex');
}
// 资料由父进程直接写标准version:1 JSON,不通过待测编码器制造测试基线。
function writeFixture(sessionsDir) {
const directory = path.join(sessionsDir, '_history', '_turns');
fs.mkdirSync(directory, { recursive: true });
const filename = path.join(directory, `${ID}.json`);
const header = JSON.stringify({ version: 1, agent: 'codexapp', sessionId: ID, turnId: 'synthetic-turn', turnStatus: 'running', special: SPECIAL }).slice(0, -1) + ',"fullText":"';
const betweenTool = '","toolCalls":[{"id":"synthetic-tool","name":"run","input":{"text":"完整输入"},"result":"';
const betweenDelta = '"}],"toolOutputDeltas":[["synthetic-tool","';
const betweenItem = '"]],"agentMessageItems":[["synthetic-item","';
const ending = '"]],"agentMessagePendingPrefixes":[],"lastUsage":{"inputTokens":1,"outputTokens":2}}';
const fixedBytes = Buffer.byteLength(header + betweenTool + betweenDelta + betweenItem + ending);
const toolBytes = 12 * 1024 * 1024;
const deltaBytes = 4 * 1024 * 1024;
const itemBytes = 4 * 1024 * 1024;
const fullTextBytes = TARGET_BYTES - fixedBytes - toolBytes - deltaBytes - itemBytes;
const fd = fs.openSync(filename, 'wx', 0o600);
function writeField(encodedBytes, piece) {
const encoded = JSON.stringify(piece).slice(1, -1);
const size = Buffer.byteLength(encoded);
const hash = crypto.createHash('sha256');
let length = 0;
let remaining = encodedBytes;
while (remaining >= size) {
fs.writeSync(fd, encoded);
hash.update(piece);
length += piece.length;
remaining -= size;
}
if (remaining) {
const final = 'z'.repeat(remaining);
fs.writeSync(fd, final);
hash.update(final);
length += final.length;
}
return { length, hash: hash.digest('hex') };
}
const expected = {};
try {
fs.writeSync(fd, header);
expected.fullText = writeField(fullTextBytes, ASCII_PIECE);
fs.writeSync(fd, betweenTool);
expected.result = writeField(toolBytes, LARGE_PIECE);
fs.writeSync(fd, betweenDelta);
expected.delta = writeField(deltaBytes, LARGE_PIECE);
fs.writeSync(fd, betweenItem);
expected.item = writeField(itemBytes, LARGE_PIECE);
fs.writeSync(fd, ending);
} finally { fs.closeSync(fd); }
assert.equal(fs.statSync(filename).size, TARGET_BYTES);
return { filename, expected };
}
function fileHash(filename) {
const hash = crypto.createHash('sha256');
const buffer = Buffer.allocUnsafe(BLOCK_BYTES);
const fd = fs.openSync(filename, 'r');
try {
let bytes;
while ((bytes = fs.readSync(fd, buffer, 0, buffer.length, null))) hash.update(buffer.subarray(0, bytes));
} finally { fs.closeSync(fd); }
return hash.digest('hex');
}
function trackedFilesystem(filename) {
const opened = new Map();
const metrics = { reads: 0, writes: 0, maxRead: 0, maxWrite: 0, readBytes: 0, writeBytes: 0 };
let failure = null;
let bytesSinceFailureMode = 0;
function runtimeFile(value) {
const resolved = typeof value === 'number' ? opened.get(value) || '' : path.resolve(String(value));
return resolved === filename || resolved.startsWith(`${filename}.`);
}
const filesystem = new Proxy(fs, { get(target, key) {
if (key === 'openSync') return (file, ...args) => {
const fd = target.openSync(file, ...args);
opened.set(fd, path.resolve(String(file)));
return fd;
};
if (key === 'closeSync') return fd => { const result = target.closeSync(fd); opened.delete(fd); return result; };
if (key === 'readFileSync') return (file, ...args) => {
assert.equal(runtimeFile(file), false, '完整运行态禁止整份readFileSync');
return target.readFileSync(file, ...args);
};
if (key === 'writeFileSync') return (file, ...args) => {
assert.equal(runtimeFile(file), false, '完整运行态禁止整份writeFileSync');
return target.writeFileSync(file, ...args);
};
if (key === 'readSync') return (fd, buffer, offset, length, position) => {
if (runtimeFile(fd)) {
assert(length <= BLOCK_BYTES, '单次运行态读取不能超过64KiB');
metrics.maxRead = Math.max(metrics.maxRead, length);
metrics.reads += 1;
}
const bytes = target.readSync(fd, buffer, offset, length, position);
if (runtimeFile(fd)) metrics.readBytes += bytes;
return bytes;
};
if (key === 'writeSync') return (fd, data, ...args) => {
if (runtimeFile(fd)) {
const bytes = typeof data === 'string' ? Buffer.byteLength(data, typeof args[1] === 'string' ? args[1] : 'utf8') : args[1] ?? data.length;
assert(bytes <= BLOCK_BYTES, '单次运行态编码写入不能超过64KiB');
metrics.maxWrite = Math.max(metrics.maxWrite, bytes);
metrics.writes += 1;
if (failure === 'write' && bytesSinceFailureMode >= 1024 * 1024) throw new Error('模拟运行态中途write失败');
const written = target.writeSync(fd, data, ...args);
metrics.writeBytes += written;
bytesSinceFailureMode += written;
return written;
}
return target.writeSync(fd, data, ...args);
};
if (key === 'fsyncSync') return fd => {
if (failure === 'fsync' && runtimeFile(fd)) throw new Error('模拟运行态fsync失败');
return target.fsyncSync(fd);
};
if (key === 'renameSync') return (from, to) => {
if (failure === 'rename' && String(to) === filename) throw new Error('模拟运行态rename失败');
return target.renameSync(from, to);
};
return target[key];
} });
return { fs: filesystem, metrics, fail(mode) { failure = mode; bytesSinceFailureMode = 0; } };
}
function assertComplete(state, fixture) {
assert.equal(state.version, 1, '旧version:1运行态须兼容');
assert.equal(state.agent, 'codexapp');
assert.equal(state.sessionId, ID);
assert.equal(state.turnStatus, 'running');
assert.deepEqual(state.special, SPECIAL);
for (const [name, value] of [['fullText', state.fullText], ['result', state.toolCalls[0].result], ['delta', state.toolOutputDeltas[0][1]], ['item', state.agentMessageItems[0][1]]]) {
assert.equal(value.length, fixture.expected[name].length, `${name} 长度发生变化`);
assert.equal(textHash(value), fixture.expected[name].hash, `${name} 正文摘要发生变化`);
}
assert.deepEqual(state.toolCalls[0].input, { text: '完整输入' });
assert.deepEqual(state.lastUsage, { inputTokens: 1, outputTokens: 2 });
}
function runChild(sessionsDir, fixture) {
const tracked = trackedFilesystem(fixture.filename);
let store = createSessionHistoryStore({ sessionsDir, fs: tracked.fs });
stage('开始读取旧version:1完整运行态');
let state = store.loadTurn(ID);
assertComplete(state, fixture);
stage('旧运行态摘要校验通过,开始分段保存');
store.saveTurn(ID, state);
const committedHash = fileHash(fixture.filename);
assert.equal(fs.statSync(fixture.filename).size, TARGET_BYTES, '完整持久化不能裁剪运行态');
stage('分段保存完成,开始原子失败注入');
state.turnStatus = 'new-uncommitted-status';
for (const mode of ['write', 'fsync', 'rename']) {
tracked.fail(mode);
assert.throws(() => store.saveTurn(ID, state), /模拟运行态/);
assert.equal(fileHash(fixture.filename), committedHash, `${mode}失败必须保留上次完整文件`);
assert.equal(fs.readdirSync(path.dirname(fixture.filename)).some(name => name.endsWith('.tmp')), false, `${mode}失败不能遗留临时文件`);
stage(`${mode}失败保护通过`);
}
tracked.fail(null);
state = null;
store = null;
if (typeof global.gc === 'function') global.gc();
const reopened = createSessionHistoryStore({ sessionsDir, fs: tracked.fs });
state = reopened.loadTurn(ID);
assertComplete(state, fixture);
assert.equal(reopened.loadTurn('missing-runtime'), null);
assert(tracked.metrics.reads > 1 && tracked.metrics.writes > 1);
console.log(JSON.stringify({ archiveMiB: TARGET_BYTES / 1024 / 1024, peakRssMiB: process.resourceUsage().maxRSS / 1024,
maxReadKiB: tracked.metrics.maxRead / 1024, maxWriteKiB: tracked.metrics.maxWrite / 1024,
reads: tracked.metrics.reads, writes: tracked.metrics.writes, failureModes: 3 }));
}
function runTests() {
const sessionsDir = fs.mkdtempSync(path.join(temporaryRoot(), 'ccweb-runtime-memory-'));
try {
stage(`开始生成80MiB合成完整运行态(${path.dirname(sessionsDir)})`);
const fixture = writeFixture(sessionsDir);
stage('合成旧运行态完成');
const child = spawnSync(process.execPath, ['--expose-gc', '--max-old-space-size=256', __filename, '--child', sessionsDir, JSON.stringify(fixture)], {
encoding: 'utf8', timeout: 60000, maxBuffer: 512 * 1024,
stdio: ['ignore', 'pipe', process.env.CCWEB_RUNTIME_TEST_PROGRESS === '1' ? 'inherit' : 'pipe'],
});
if (child.error || child.status !== 0) process.stderr.write(child.stderr || '');
assert.ifError(child.error);
assert.equal(child.status, 0, `256MiB堆运行态子进程失败(信号:${child.signal || '无'}):${child.stderr || child.stdout}`);
const result = JSON.parse(child.stdout.trim());
console.log(`通过:${result.archiveMiB.toFixed(1)} MiB完整运行态兼容读取、分段保存、重开后四项正文摘要一致`);
console.log(`通过:write/fsync/rename三类失败保留旧文件且无临时文件,单次读取/写入最大 ${result.maxReadKiB.toFixed(1)}/${result.maxWriteKiB.toFixed(1)} KiB`);
console.log(`内存:256 MiB堆限制下RSS峰值 ${result.peakRssMiB.toFixed(1)} MiB;读取仍持有完整运行态对象,分段处理限制临时编码副本`);
return result;
} finally { fs.rmSync(sessionsDir, { recursive: true, force: true }); }
}
if (require.main === module) {
if (process.argv[2] === '--child') runChild(process.argv[3], JSON.parse(process.argv[4]));
else runTests();
}
module.exports = { runTests };