chore: initial commit
This commit is contained in:
@@ -0,0 +1,347 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {EventEmitter} from 'node:events';
|
||||
import {mkdtemp, mkdir, writeFile, symlink, rm} from 'node:fs/promises';
|
||||
import {tmpdir} from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {performance} from 'node:perf_hooks';
|
||||
import {test} from 'node:test';
|
||||
import {register} from 'tsx/esm/api';
|
||||
|
||||
// One module graph is essential: separate tsImport namespaces would isolate the shared WeakMap meter.
|
||||
register();
|
||||
const {instrument_bot, measure_sync, ResourceInfo} = await import('../bot/info/resource_metrics.ts');
|
||||
const {init: CpuInfo} = await import('../bot/info/instance/used_cpu.ts');
|
||||
const {NetworkCounter, init: NetworkInfo} = await import('../bot/info/instance/used_network.ts');
|
||||
const {estimate_memory, init: MemoryInfo} = await import('../bot/info/instance/used_memory.ts');
|
||||
const {scan_storage, init: StorageInfo} = await import('../bot/info/instance/used_storage.ts');
|
||||
const {create_info_manager} = await import('../bot/info/init.ts');
|
||||
const {create_function_manager} = await import('../bot/function/init.ts');
|
||||
|
||||
function bot() {
|
||||
const bot = new EventEmitter();
|
||||
bot._client = new EventEmitter();
|
||||
return bot;
|
||||
}
|
||||
|
||||
test('sync meter deduplicates nested bot/client events and preserves invocation semantics', t => {
|
||||
const clock = t.mock.method(performance, 'now');
|
||||
let now = 0;
|
||||
clock.mock.mockImplementation(() => now);
|
||||
const b = bot();
|
||||
const original = b.emit;
|
||||
const clientOriginal = b._client.emit;
|
||||
const meter = instrument_bot(b);
|
||||
b._client.on('packet', function(a, c) {
|
||||
assert.equal(this, b._client);
|
||||
assert.deepEqual([a, c], [1, 2]);
|
||||
now += 3;
|
||||
b.emit('physicsTick');
|
||||
now += 2;
|
||||
});
|
||||
b.on('physicsTick', () => { now += 5; });
|
||||
assert.equal(b._client.emit('packet', 1, 2), true);
|
||||
assert.equal(meter.total(), 10);
|
||||
assert.equal(b.emit('absent'), false);
|
||||
const error = new Error('same error');
|
||||
b.on('failure', () => { now += 7; throw error; });
|
||||
assert.throws(() => b.emit('failure'), e => e === error);
|
||||
assert.equal(meter.total(), 17);
|
||||
const other = bot();
|
||||
other.on('x', () => { now += 100; });
|
||||
other.emit('x');
|
||||
assert.equal(meter.total(), 17);
|
||||
meter.dispose(); meter.dispose();
|
||||
assert.equal(b.emit, original);
|
||||
assert.equal(b._client.emit, clientOriginal);
|
||||
assert.equal(Object.hasOwn(b, 'emit'), false);
|
||||
});
|
||||
|
||||
test('sync invocation never awaits promises and disposal respects replacement hooks', async t => {
|
||||
let now = 0;
|
||||
t.mock.method(performance, 'now', () => now);
|
||||
const b = bot();
|
||||
const meter = instrument_bot(b);
|
||||
let release;
|
||||
const pending = new Promise(resolve => { release = resolve; });
|
||||
assert.equal(measure_sync(b, () => { now += 4; return pending; }), pending);
|
||||
now += 500;
|
||||
release(true);
|
||||
await pending;
|
||||
assert.equal(meter.total(), 4);
|
||||
const replacement = () => 'replacement';
|
||||
b.emit = replacement;
|
||||
meter.dispose();
|
||||
assert.equal(b.emit, replacement);
|
||||
});
|
||||
|
||||
test('CPU samples actual elapsed deltas and cancels timer on disposal', async t => {
|
||||
t.mock.timers.enable({apis: ['setInterval']});
|
||||
let now = 0;
|
||||
t.mock.method(performance, 'now', () => now);
|
||||
const b = bot();
|
||||
const info = new CpuInfo(b);
|
||||
assert.equal(info.get_info().usage_percent, null);
|
||||
b.on('physicsTick', () => { now += 20; });
|
||||
b.emit('physicsTick');
|
||||
now = 2000;
|
||||
t.mock.timers.tick(1000);
|
||||
assert.equal(info.get_info().usage_percent, 1);
|
||||
assert.equal(info.get_info().sampled_time_ms, 20);
|
||||
assert.equal(info.get_info().interval_ms, 2000);
|
||||
assert.match(info.get_info().coverage, /协议解析/);
|
||||
let updates = 0;
|
||||
info.on_update(() => updates++);
|
||||
info.dispose();
|
||||
await Promise.resolve();
|
||||
t.mock.timers.tick(5000);
|
||||
assert.equal(updates, 0);
|
||||
assert.equal(b.listenerCount('end'), 0);
|
||||
assert.throws(() => info.on_update(() => {}), /disposed/);
|
||||
});
|
||||
|
||||
test('function manager meters synchronous run invocation, with no promise wait', async t => {
|
||||
const b = bot();
|
||||
b.entity = {isValid: true};
|
||||
b._client.state = 'play';
|
||||
const manager = await create_function_manager({bot_instance: b, bot_status: 'online'});
|
||||
const meter = instrument_bot(b);
|
||||
t.after(() => { manager.dispose(); meter.dispose(); });
|
||||
let now = 0;
|
||||
t.mock.method(performance, 'now', () => now);
|
||||
b.chat = () => { now += 12; };
|
||||
assert.equal(await manager.run('bot', 'chat', ['hello']), true);
|
||||
assert.equal(meter.total(), 12);
|
||||
});
|
||||
|
||||
test('socket counters use elapsed intervals and retain observed totals across resets and gaps', () => {
|
||||
const counter = new NetworkCounter();
|
||||
const first = {bytesRead: 100, bytesWritten: 50};
|
||||
assert.equal(counter.sample(undefined, 0).available, false);
|
||||
assert.equal(counter.sample(first, 100).received_bytes_per_second, null);
|
||||
first.bytesRead += 300; first.bytesWritten += 100;
|
||||
const sample = counter.sample(first, 1600);
|
||||
assert.equal(sample.received_bytes_per_second, 200);
|
||||
assert.equal(sample.sent_bytes_per_second, 100 / 1.5);
|
||||
assert.equal(sample.received_bytes, 400);
|
||||
const second = {bytesRead: 20, bytesWritten: 10};
|
||||
assert.equal(counter.sample(second, 2000).received_bytes_per_second, null);
|
||||
assert.equal(counter.sample(undefined, 2500).received_bytes, 420);
|
||||
second.bytesRead = 30;
|
||||
assert.equal(counter.sample(second, 3000).received_bytes, 430);
|
||||
second.bytesRead = 2;
|
||||
assert.equal(counter.sample(second, 4000).received_bytes_per_second, null);
|
||||
assert.equal(counter.sample(second, 5000).received_bytes, 432);
|
||||
});
|
||||
|
||||
test('memory scans own descriptors, real columns, cycles and shared backing buffers without getters', async () => {
|
||||
const backing = new ArrayBuffer(1024);
|
||||
const column = {a: new Uint8Array(backing), b: new DataView(backing)};
|
||||
Object.defineProperty(column.a, 'buffer', {get() { throw new Error('no getter'); }});
|
||||
const b = bot();
|
||||
b.registry = {large: new ArrayBuffer(100000)};
|
||||
b.world = {getColumns: () => [{chunkX: 0, chunkZ: 0, column}], external: b.registry};
|
||||
b.inventory = {slots: [column.a], registry: b.registry};
|
||||
b.currentWindow = b.inventory;
|
||||
b.entities = {one: {name: 'one'}};
|
||||
b.players = {one: b.entities.one};
|
||||
b.entities.one.self = b.entities.one;
|
||||
const original = await estimate_memory(b);
|
||||
assert.equal(original.partial, false);
|
||||
assert.ok(original.total_bytes >= 1024 && original.total_bytes < 3000);
|
||||
const duplicated = await estimate_memory({...b, currentWindow: {...b.inventory}});
|
||||
assert.ok(duplicated.total_bytes > original.total_bytes);
|
||||
Object.defineProperty(b.entities.one, 'danger', {get() { throw new Error('getter executed'); }});
|
||||
assert.equal((await estimate_memory(b)).partial, true);
|
||||
assert.equal((await estimate_memory(b, () => true)).partial, true);
|
||||
assert.equal((await estimate_memory(b, () => false, {nodes: 1, properties: 10, milliseconds: 1000})).partial, true);
|
||||
b.world.getColumns = () => { throw new Error('unavailable'); };
|
||||
assert.equal((await estimate_memory(b)).partial, true);
|
||||
});
|
||||
|
||||
for (const hugeCategory of ['chunks', 'entities']) {
|
||||
for (const cap of ['nodes', 'properties']) {
|
||||
test(`memory reserves category budgets with huge ${hugeCategory} and a ${cap} cap`, async () => {
|
||||
const b = bot();
|
||||
// Wide objects exercise ownKeys; nested objects exercise independent node limits.
|
||||
const huge = Object.fromEntries(Array.from({length: 10000}, (_, i) => [`key${i}`, {value: i}]));
|
||||
b.world = {getColumns: () => [{column: hugeCategory === 'chunks' ? huge : {data: new Uint8Array(64)}}]};
|
||||
b.entities = hugeCategory === 'entities' ? huge : {one: {name: 'entity'}};
|
||||
b.inventory = {slots: [{name: 'stone'}]};
|
||||
b.time = {age: 100n};
|
||||
const limits = {nodes: 100000, properties: 100000, milliseconds: 10000, [cap]: 80};
|
||||
const result = await estimate_memory(b, () => false, limits);
|
||||
for (const category of ['chunks', 'entities', 'inventory', 'other']) {
|
||||
assert.ok(result[`${category}_bytes`] > 0, category);
|
||||
assert.equal(result.categories_complete[category], category !== hugeCategory, category);
|
||||
}
|
||||
assert.equal(result.partial, true);
|
||||
assert.equal(result.total_bytes, result.chunks_bytes + result.entities_bytes
|
||||
+ result.inventory_bytes + result.other_bytes);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test('memory deduplicates shared objects and backing stores across categories deterministically', async () => {
|
||||
const sample = async size => {
|
||||
const b = bot();
|
||||
const backing = new ArrayBuffer(size);
|
||||
const shared = {view: new Uint8Array(backing)};
|
||||
shared.self = shared;
|
||||
b.world = {getColumns: () => [{column: {shared, view: new DataView(backing)}}]};
|
||||
b.entities = {shared, view: new Uint8Array(backing)};
|
||||
b.inventory = {shared, view: new DataView(backing)};
|
||||
b.game = {shared, backing};
|
||||
return estimate_memory(b, () => false, {nodes: 1000, properties: 1000, milliseconds: 10000});
|
||||
};
|
||||
const small = await sample(100);
|
||||
const large = await sample(1100);
|
||||
assert.equal(large.total_bytes - small.total_bytes, 1000);
|
||||
assert.deepEqual(large, await sample(1100));
|
||||
assert.deepEqual(large.categories_complete, {chunks: true, entities: true, inventory: true, other: true});
|
||||
assert.equal(large.partial, false);
|
||||
});
|
||||
|
||||
test('memory scans only allowlisted other roots and reports omissions without calling getters', async () => {
|
||||
const b = bot();
|
||||
const roots = ['game', 'time', 'settings', 'scoreboards', 'teams', 'tablist'];
|
||||
for (const key of roots) {
|
||||
const result = await estimate_memory(Object.assign(bot(), {[key]: {data: 'owned'}}));
|
||||
assert.ok(result.other_bytes > 0, key);
|
||||
assert.equal(result.categories_complete.other, true);
|
||||
}
|
||||
b.unlisted = {data: new ArrayBuffer(1000000)};
|
||||
assert.equal((await estimate_memory(b)).other_bytes, 0);
|
||||
let getters = 0;
|
||||
Object.defineProperty(b, 'game', {get() { getters++; return {}; }});
|
||||
b.world = {};
|
||||
Object.defineProperty(b.world, 'getColumns', {get() { getters++; return () => []; }});
|
||||
b.entities = {one: {name: 'one'}};
|
||||
Object.defineProperty(b.entities.one, 'danger', {get() { getters++; return 123; }});
|
||||
const result = await estimate_memory(b);
|
||||
assert.equal(getters, 0);
|
||||
assert.deepEqual(result.categories_complete, {chunks: false, entities: false, inventory: true, other: false});
|
||||
assert.equal(result.partial, true);
|
||||
for (const limits of [{nodes: 0, properties: 100, milliseconds: 1000},
|
||||
{nodes: 100, properties: 0, milliseconds: 1000},
|
||||
{nodes: 100, properties: 100, milliseconds: 0}]) {
|
||||
const capped = await estimate_memory(b, () => false, limits);
|
||||
assert.equal(capped.categories_complete.entities, false);
|
||||
assert.equal(capped.entities_bytes, 0);
|
||||
assert.equal(capped.partial, true);
|
||||
}
|
||||
const cancelled = await estimate_memory(b, () => true);
|
||||
assert.deepEqual(cancelled.categories_complete, {chunks: false, entities: false, inventory: false, other: false});
|
||||
});
|
||||
|
||||
test('memory marks column and unsupported collection truncation per category', async () => {
|
||||
const b = bot();
|
||||
b.world = {getColumns: () => Array.from({length: 4097}, () => ({column: {}}))};
|
||||
b.game = new Map([['data', 'omitted']]);
|
||||
const result = await estimate_memory(b, () => false, {nodes: 50000, properties: 50000, milliseconds: 10000});
|
||||
assert.deepEqual(result.categories_complete, {chunks: false, entities: true, inventory: true, other: false});
|
||||
assert.equal(result.partial, true);
|
||||
});
|
||||
|
||||
test('storage scans nested files without following symlinks and reports missing, error and bounds', async t => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), 'mf-resource-'));
|
||||
t.after(() => rm(directory, {recursive: true, force: true}));
|
||||
await mkdir(path.join(directory, 'nested'));
|
||||
await writeFile(path.join(directory, 'a'), 'abc');
|
||||
await writeFile(path.join(directory, 'nested', 'b'), '12345');
|
||||
await symlink(directory, path.join(directory, 'nested', 'loop'));
|
||||
await symlink(path.join(directory, 'a'), path.join(directory, 'alias'));
|
||||
assert.deepEqual(await scan_storage(directory), {available: true, total_bytes: 8, file_count: 2, partial: false});
|
||||
assert.deepEqual(await scan_storage(path.join(directory, 'missing')),
|
||||
{available: true, total_bytes: 0, file_count: 0, partial: false});
|
||||
assert.equal((await scan_storage(path.join(directory, 'a'))).available, false);
|
||||
assert.equal((await scan_storage(directory, () => true)).partial, true);
|
||||
assert.equal((await scan_storage(directory, () => false, {entries: 2, depth: 32, milliseconds: 1000})).partial, true);
|
||||
assert.equal((await scan_storage(directory, () => false, {entries: 100, depth: 1, milliseconds: 1000})).partial, true);
|
||||
});
|
||||
|
||||
test('resource modules have complete field metadata, no end listeners and safe async disposal', async t => {
|
||||
t.mock.timers.enable({apis: ['setInterval']});
|
||||
const b = bot();
|
||||
const infos = [new CpuInfo(b), new NetworkInfo(b), new MemoryInfo(b), new StorageInfo(b)];
|
||||
assert.deepEqual(infos[2].get_info().categories_complete,
|
||||
{chunks: false, entities: false, inventory: false, other: false});
|
||||
assert.equal(infos[2].get_info().partial, true);
|
||||
let updates = 0;
|
||||
for (const info of infos) {
|
||||
assert.equal(info.i18n.lang, 'zh-CN');
|
||||
assert.match(info.i18n.name, /[\u4e00-\u9fff]/u);
|
||||
assert.match(info.i18n.description, /[\u4e00-\u9fff]/u);
|
||||
for (const key of Object.keys(info.get_info())) {
|
||||
const field = info.i18n.fields[key];
|
||||
assert.ok(field, key);
|
||||
assert.equal(field.lang, 'zh-CN');
|
||||
assert.notEqual(field.name, key);
|
||||
assert.match(field.name, /[\u4e00-\u9fff]/u);
|
||||
assert.match(field.description, /[\u4e00-\u9fff]/u);
|
||||
}
|
||||
const checkFields = (value, metadata) => {
|
||||
for (const [key, entry] of Object.entries(value)) {
|
||||
const field = metadata.fields[key];
|
||||
assert.ok(field, key);
|
||||
assert.equal(field.lang, 'zh-CN');
|
||||
assert.match(field.name, /[\u4e00-\u9fff]/u);
|
||||
assert.match(field.description, /[\u4e00-\u9fff]/u);
|
||||
if (entry && typeof entry === 'object') checkFields(entry, field);
|
||||
}
|
||||
};
|
||||
checkFields(info.get_info(), info.i18n);
|
||||
info.on_update(() => updates++);
|
||||
}
|
||||
assert.equal(b.listenerCount('end'), 0);
|
||||
for (const info of infos) { info.dispose(); info.dispose(); }
|
||||
await Promise.resolve();
|
||||
t.mock.timers.tick(60000);
|
||||
assert.equal(updates, 0);
|
||||
assert.equal(infos[3].get_info().available, false);
|
||||
});
|
||||
|
||||
test('async resource sampling does not overlap or publish after disposal', async t => {
|
||||
t.mock.timers.enable({apis: ['setInterval']});
|
||||
let release;
|
||||
let calls = 0;
|
||||
class Sample extends ResourceInfo {
|
||||
name = 'sample';
|
||||
i18n = {lang: 'en', name: 'Sample', description: ''};
|
||||
constructor() {
|
||||
super(0);
|
||||
this.start(1000, async () => {
|
||||
calls++;
|
||||
await new Promise(resolve => { release = resolve; });
|
||||
this.publish(1);
|
||||
}, true);
|
||||
}
|
||||
}
|
||||
const sample = new Sample();
|
||||
t.mock.timers.tick(30000);
|
||||
assert.equal(calls, 1);
|
||||
sample.dispose();
|
||||
release();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
assert.equal(sample.get_info(), 0);
|
||||
t.mock.timers.tick(30000);
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
|
||||
test('InfoManager discovers all resource modules and owns wrappers through end/recreation', async () => {
|
||||
const b = bot();
|
||||
const original = b.emit;
|
||||
const manager = await create_info_manager(b);
|
||||
for (const name of ['used_cpu', 'used_memory', 'used_network', 'used_storage']) {
|
||||
assert.ok(manager.get_info(name), name);
|
||||
}
|
||||
assert.equal(b.listenerCount('end'), 1);
|
||||
assert.notEqual(b.emit, original);
|
||||
b.emit('end');
|
||||
assert.equal(b.emit, original);
|
||||
assert.equal(b.listenerCount('end'), 0);
|
||||
assert.equal(manager.get_all_info().length, 0);
|
||||
const next = await create_info_manager(b);
|
||||
next.dispose();
|
||||
assert.equal(b.emit, original);
|
||||
});
|
||||
Reference in New Issue
Block a user