mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): establish 3.0 incubation baseline
This commit is contained in:
@@ -0,0 +1,506 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const crypto = require('node:crypto');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { performance } = require('node:perf_hooks');
|
||||
const {
|
||||
canonicalDigest,
|
||||
writeNoReplace,
|
||||
} = require('./ql3-physical-edge-evidence.cjs');
|
||||
|
||||
const MAX_PROC_BYTES = 64 * 1024;
|
||||
const MANIFEST_KEYS = Object.freeze([
|
||||
'deviceId',
|
||||
'durationSeconds',
|
||||
'evidenceClass',
|
||||
'expectedExecutable',
|
||||
'processId',
|
||||
'profile',
|
||||
'sampleIntervalMs',
|
||||
'schemaVersion',
|
||||
]);
|
||||
|
||||
class QingLong3PhysicalIdleEvidenceError extends Error {
|
||||
constructor(message) {
|
||||
super(`QingLong 3.0 physical Edge idle evidence failed: ${message}`);
|
||||
this.name = 'QingLong3PhysicalIdleEvidenceError';
|
||||
}
|
||||
}
|
||||
|
||||
function exactKeys(value, expected, label) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new QingLong3PhysicalIdleEvidenceError(`${label} must be an object`);
|
||||
}
|
||||
const actual = Object.keys(value).sort();
|
||||
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
||||
throw new QingLong3PhysicalIdleEvidenceError(
|
||||
`${label} keys must be exactly ${expected.join(', ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeIdleManifest(value) {
|
||||
exactKeys(value, MANIFEST_KEYS, 'manifest');
|
||||
if (value.schemaVersion !== 1) {
|
||||
throw new QingLong3PhysicalIdleEvidenceError(
|
||||
'manifest schemaVersion must be 1',
|
||||
);
|
||||
}
|
||||
if (value.evidenceClass !== 'physical_edge_idle_candidate') {
|
||||
throw new QingLong3PhysicalIdleEvidenceError(
|
||||
'manifest evidenceClass must be physical_edge_idle_candidate',
|
||||
);
|
||||
}
|
||||
if (value.profile !== 'edge') {
|
||||
throw new QingLong3PhysicalIdleEvidenceError(
|
||||
'manifest profile must be edge',
|
||||
);
|
||||
}
|
||||
if (
|
||||
typeof value.deviceId !== 'string' ||
|
||||
!/^[a-z0-9][a-z0-9._-]{2,63}$/.test(value.deviceId)
|
||||
) {
|
||||
throw new QingLong3PhysicalIdleEvidenceError(
|
||||
'manifest deviceId is invalid',
|
||||
);
|
||||
}
|
||||
if (
|
||||
!Number.isSafeInteger(value.processId) ||
|
||||
value.processId < 2 ||
|
||||
value.processId > 4_194_304
|
||||
) {
|
||||
throw new QingLong3PhysicalIdleEvidenceError(
|
||||
'manifest processId is invalid',
|
||||
);
|
||||
}
|
||||
if (
|
||||
typeof value.expectedExecutable !== 'string' ||
|
||||
!path.isAbsolute(value.expectedExecutable) ||
|
||||
value.expectedExecutable.length > 4096
|
||||
) {
|
||||
throw new QingLong3PhysicalIdleEvidenceError(
|
||||
'manifest expectedExecutable must be a bounded absolute path',
|
||||
);
|
||||
}
|
||||
if (
|
||||
!Number.isSafeInteger(value.durationSeconds) ||
|
||||
value.durationSeconds < 30 ||
|
||||
value.durationSeconds > 3600
|
||||
) {
|
||||
throw new QingLong3PhysicalIdleEvidenceError(
|
||||
'manifest durationSeconds must be between 30 and 3600',
|
||||
);
|
||||
}
|
||||
if (
|
||||
!Number.isSafeInteger(value.sampleIntervalMs) ||
|
||||
value.sampleIntervalMs < 1000 ||
|
||||
value.sampleIntervalMs > 60_000 ||
|
||||
(value.durationSeconds * 1000) % value.sampleIntervalMs !== 0
|
||||
) {
|
||||
throw new QingLong3PhysicalIdleEvidenceError(
|
||||
'manifest sampleIntervalMs must divide the duration and be between 1000 and 60000',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
evidenceClass: 'physical_edge_idle_candidate',
|
||||
profile: 'edge',
|
||||
deviceId: value.deviceId,
|
||||
processId: value.processId,
|
||||
expectedExecutable: value.expectedExecutable,
|
||||
durationSeconds: value.durationSeconds,
|
||||
sampleIntervalMs: value.sampleIntervalMs,
|
||||
});
|
||||
}
|
||||
|
||||
function parseArguments(argv) {
|
||||
const options = { json: false };
|
||||
let separatorSeen = false;
|
||||
for (const argument of argv) {
|
||||
if (argument === '--' && !separatorSeen) {
|
||||
separatorSeen = true;
|
||||
continue;
|
||||
}
|
||||
if (argument === '--json') {
|
||||
options.json = true;
|
||||
continue;
|
||||
}
|
||||
const separator = argument.indexOf('=');
|
||||
if (separator < 1) {
|
||||
throw new QingLong3PhysicalIdleEvidenceError(
|
||||
`unsupported argument ${argument}`,
|
||||
);
|
||||
}
|
||||
const name = argument.slice(0, separator);
|
||||
const value = argument.slice(separator + 1);
|
||||
if (name === '--manifest') options.manifestPath = value;
|
||||
else if (name === '--output') options.outputPath = value;
|
||||
else {
|
||||
throw new QingLong3PhysicalIdleEvidenceError(
|
||||
`unsupported argument ${argument}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const name of ['manifestPath', 'outputPath']) {
|
||||
if (!path.isAbsolute(options[name] ?? '')) {
|
||||
throw new QingLong3PhysicalIdleEvidenceError(`${name} must be absolute`);
|
||||
}
|
||||
}
|
||||
return Object.freeze(options);
|
||||
}
|
||||
|
||||
function readBoundedFile(filePath, label) {
|
||||
const stat = fs.lstatSync(filePath);
|
||||
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_PROC_BYTES) {
|
||||
throw new QingLong3PhysicalIdleEvidenceError(
|
||||
`${label} must be a bounded regular file`,
|
||||
);
|
||||
}
|
||||
const value = fs.readFileSync(filePath);
|
||||
if (value.length > MAX_PROC_BYTES) {
|
||||
throw new QingLong3PhysicalIdleEvidenceError(`${label} is oversized`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function readManifest(filePath) {
|
||||
try {
|
||||
return normalizeIdleManifest(
|
||||
JSON.parse(readBoundedFile(filePath, 'manifest').toString('utf8')),
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof QingLong3PhysicalIdleEvidenceError) throw error;
|
||||
throw new QingLong3PhysicalIdleEvidenceError(
|
||||
`manifest could not be read: ${error.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function safeInteger(raw, label, { allowNegative = false } = {}) {
|
||||
if (!/^-?\d+$/.test(raw)) {
|
||||
throw new QingLong3PhysicalIdleEvidenceError(`${label} is invalid`);
|
||||
}
|
||||
const value = Number(raw);
|
||||
if (!Number.isSafeInteger(value) || (!allowNegative && value < 0)) {
|
||||
throw new QingLong3PhysicalIdleEvidenceError(`${label} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseProcStat(raw) {
|
||||
const close = raw.lastIndexOf(') ');
|
||||
if (!raw.startsWith('(') && !/^\d+ \(/.test(raw)) {
|
||||
throw new QingLong3PhysicalIdleEvidenceError('process stat is malformed');
|
||||
}
|
||||
if (close < 4) {
|
||||
throw new QingLong3PhysicalIdleEvidenceError('process stat is malformed');
|
||||
}
|
||||
const open = raw.indexOf('(');
|
||||
const processId = safeInteger(raw.slice(0, open).trim(), 'process stat pid');
|
||||
const fields = raw
|
||||
.slice(close + 2)
|
||||
.trim()
|
||||
.split(/\s+/);
|
||||
if (fields.length < 22 || !/^[RSDTtXZIKWP]$/.test(fields[0])) {
|
||||
throw new QingLong3PhysicalIdleEvidenceError('process stat is malformed');
|
||||
}
|
||||
return Object.freeze({
|
||||
processId,
|
||||
state: fields[0],
|
||||
minorFaults: safeInteger(fields[7], 'process stat minor faults'),
|
||||
majorFaults: safeInteger(fields[9], 'process stat major faults'),
|
||||
userTicks: safeInteger(fields[11], 'process stat user ticks'),
|
||||
systemTicks: safeInteger(fields[12], 'process stat system ticks'),
|
||||
threadCount: safeInteger(fields[17], 'process stat thread count'),
|
||||
startTicks: safeInteger(fields[19], 'process stat start ticks'),
|
||||
rssPages: safeInteger(fields[21], 'process stat RSS pages'),
|
||||
});
|
||||
}
|
||||
|
||||
function linesByName(raw, separator) {
|
||||
const values = new Map();
|
||||
for (const line of raw.trim().split('\n')) {
|
||||
const index = line.indexOf(separator);
|
||||
if (index < 1) continue;
|
||||
values.set(
|
||||
line.slice(0, index),
|
||||
line.slice(index + separator.length).trim(),
|
||||
);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function parseProcStatus(raw) {
|
||||
const values = linesByName(raw, ':');
|
||||
const rss = values.get('VmRSS')?.match(/^(\d+) kB$/);
|
||||
const uid = values.get('Uid')?.split(/\s+/).map(Number);
|
||||
if (
|
||||
!rss ||
|
||||
uid?.length !== 4 ||
|
||||
uid.some((value) => !Number.isSafeInteger(value))
|
||||
) {
|
||||
throw new QingLong3PhysicalIdleEvidenceError('process status is malformed');
|
||||
}
|
||||
return Object.freeze({
|
||||
uid: uid[0],
|
||||
rssBytes: safeInteger(rss[1], 'process status RSS') * 1024,
|
||||
threadCount: safeInteger(
|
||||
values.get('Threads') ?? '',
|
||||
'process status thread count',
|
||||
),
|
||||
voluntaryContextSwitches: safeInteger(
|
||||
values.get('voluntary_ctxt_switches') ?? '',
|
||||
'process status voluntary context switches',
|
||||
),
|
||||
involuntaryContextSwitches: safeInteger(
|
||||
values.get('nonvoluntary_ctxt_switches') ?? '',
|
||||
'process status involuntary context switches',
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function parseProcIo(raw) {
|
||||
const values = linesByName(raw, ':');
|
||||
const read = (name) =>
|
||||
safeInteger(values.get(name) ?? '', `process I/O ${name}`);
|
||||
return Object.freeze({
|
||||
readBytes: read('read_bytes'),
|
||||
writeBytes: read('write_bytes'),
|
||||
readSyscalls: read('syscr'),
|
||||
writeSyscalls: read('syscw'),
|
||||
});
|
||||
}
|
||||
|
||||
function readProcessSnapshot(processId) {
|
||||
const root = `/proc/${processId}`;
|
||||
const stat = parseProcStat(
|
||||
readBoundedFile(`${root}/stat`, 'process stat').toString('utf8'),
|
||||
);
|
||||
const status = parseProcStatus(
|
||||
readBoundedFile(`${root}/status`, 'process status').toString('utf8'),
|
||||
);
|
||||
const io = parseProcIo(
|
||||
readBoundedFile(`${root}/io`, 'process I/O').toString('utf8'),
|
||||
);
|
||||
const command = readBoundedFile(`${root}/cmdline`, 'process command');
|
||||
if (command.length === 0) {
|
||||
throw new QingLong3PhysicalIdleEvidenceError('process command is empty');
|
||||
}
|
||||
return Object.freeze({
|
||||
monotonicMs: performance.now(),
|
||||
wallTimeMs: Date.now(),
|
||||
bootId: readBoundedFile('/proc/sys/kernel/random/boot_id', 'boot identity')
|
||||
.toString('utf8')
|
||||
.trim(),
|
||||
executable: fs.realpathSync(`${root}/exe`),
|
||||
commandSha256: crypto.createHash('sha256').update(command).digest('hex'),
|
||||
...stat,
|
||||
...status,
|
||||
...io,
|
||||
});
|
||||
}
|
||||
|
||||
function delta(first, last, name, violations) {
|
||||
const value = last[name] - first[name];
|
||||
if (!Number.isSafeInteger(value) || value < 0) {
|
||||
violations.push(`${name} counter regressed`);
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function validateSnapshotSeries(manifest, snapshots) {
|
||||
const violations = [];
|
||||
const expectedSamples =
|
||||
(manifest.durationSeconds * 1000) / manifest.sampleIntervalMs + 1;
|
||||
if (snapshots.length !== expectedSamples) {
|
||||
violations.push(
|
||||
`sample count ${snapshots.length} did not equal ${expectedSamples}`,
|
||||
);
|
||||
}
|
||||
const first = snapshots[0];
|
||||
if (!first) return Object.freeze(violations);
|
||||
for (const [index, snapshot] of snapshots.entries()) {
|
||||
if (snapshot.processId !== manifest.processId) {
|
||||
violations.push(`sample ${index} process identity changed`);
|
||||
}
|
||||
if (snapshot.executable !== manifest.expectedExecutable) {
|
||||
violations.push(`sample ${index} executable changed`);
|
||||
}
|
||||
if (snapshot.bootId !== first.bootId) {
|
||||
violations.push(`sample ${index} boot identity changed`);
|
||||
}
|
||||
if (snapshot.startTicks !== first.startTicks) {
|
||||
violations.push(`sample ${index} process generation changed`);
|
||||
}
|
||||
if (snapshot.commandSha256 !== first.commandSha256) {
|
||||
violations.push(`sample ${index} command identity changed`);
|
||||
}
|
||||
if (snapshot.uid !== first.uid) {
|
||||
violations.push(`sample ${index} process UID changed`);
|
||||
}
|
||||
if (['Z', 'X'].includes(snapshot.state)) {
|
||||
violations.push(`sample ${index} process was not live`);
|
||||
}
|
||||
if (index > 0 && snapshot.monotonicMs <= snapshots[index - 1].monotonicMs) {
|
||||
violations.push(`sample ${index} monotonic time did not advance`);
|
||||
}
|
||||
}
|
||||
return Object.freeze([...new Set(violations)]);
|
||||
}
|
||||
|
||||
function percentile(sorted, percentage) {
|
||||
return sorted[
|
||||
Math.min(sorted.length - 1, Math.ceil(sorted.length * percentage) - 1)
|
||||
];
|
||||
}
|
||||
|
||||
function summarizeSnapshots(snapshots, violations = []) {
|
||||
if (snapshots.length < 2) {
|
||||
throw new QingLong3PhysicalIdleEvidenceError(
|
||||
'at least two process samples are required',
|
||||
);
|
||||
}
|
||||
const first = snapshots[0];
|
||||
const last = snapshots[snapshots.length - 1];
|
||||
const rss = snapshots.map(({ rssBytes }) => rssBytes).sort((a, b) => a - b);
|
||||
return Object.freeze({
|
||||
sampleCount: snapshots.length,
|
||||
actualDurationMs: Number((last.monotonicMs - first.monotonicMs).toFixed(3)),
|
||||
rssBytes: Object.freeze({
|
||||
minimum: rss[0],
|
||||
p50: percentile(rss, 0.5),
|
||||
p95: percentile(rss, 0.95),
|
||||
maximum: rss[rss.length - 1],
|
||||
final: last.rssBytes,
|
||||
}),
|
||||
threadCountMaximum: Math.max(
|
||||
...snapshots.map(({ threadCount }) => threadCount),
|
||||
),
|
||||
cpuTicks: Object.freeze({
|
||||
user: delta(first, last, 'userTicks', violations),
|
||||
system: delta(first, last, 'systemTicks', violations),
|
||||
}),
|
||||
faults: Object.freeze({
|
||||
minor: delta(first, last, 'minorFaults', violations),
|
||||
major: delta(first, last, 'majorFaults', violations),
|
||||
}),
|
||||
contextSwitches: Object.freeze({
|
||||
voluntary: delta(first, last, 'voluntaryContextSwitches', violations),
|
||||
involuntary: delta(first, last, 'involuntaryContextSwitches', violations),
|
||||
}),
|
||||
io: Object.freeze({
|
||||
readBytes: delta(first, last, 'readBytes', violations),
|
||||
writeBytes: delta(first, last, 'writeBytes', violations),
|
||||
readSyscalls: delta(first, last, 'readSyscalls', violations),
|
||||
writeSyscalls: delta(first, last, 'writeSyscalls', violations),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function buildIdleEvidenceReport({ manifest, snapshots, generatedAt }) {
|
||||
const violations = [...validateSnapshotSeries(manifest, snapshots)];
|
||||
const summary = summarizeSnapshots(snapshots, violations);
|
||||
const first = snapshots[0];
|
||||
const body = {
|
||||
schemaVersion: 1,
|
||||
evidenceClass: 'physical_edge_idle_candidate',
|
||||
supported: false,
|
||||
generatedAt,
|
||||
manifest,
|
||||
identity: {
|
||||
platform: process.platform,
|
||||
architecture: process.arch,
|
||||
node: process.version,
|
||||
bootId: first.bootId,
|
||||
processId: first.processId,
|
||||
processUid: first.uid,
|
||||
executable: first.executable,
|
||||
commandSha256: first.commandSha256,
|
||||
startTicks: first.startTicks,
|
||||
},
|
||||
summary,
|
||||
qualification: {
|
||||
passed: violations.length === 0,
|
||||
violations,
|
||||
measures: [
|
||||
'process_rss',
|
||||
'process_cpu_ticks',
|
||||
'process_context_switches',
|
||||
'process_io',
|
||||
],
|
||||
doesNotProve: [
|
||||
'whole_device_cpu_wakeups',
|
||||
'whole_device_flash_write_amplification',
|
||||
'cold_start_or_first_ready',
|
||||
],
|
||||
},
|
||||
};
|
||||
return Object.freeze({ ...body, sha256: canonicalDigest(body) });
|
||||
}
|
||||
|
||||
function delay(milliseconds) {
|
||||
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||
}
|
||||
|
||||
async function collectSnapshots(manifest) {
|
||||
if (process.platform !== 'linux') {
|
||||
throw new QingLong3PhysicalIdleEvidenceError('sampler requires Linux');
|
||||
}
|
||||
const canonicalExecutable = fs.realpathSync(manifest.expectedExecutable);
|
||||
if (canonicalExecutable !== manifest.expectedExecutable) {
|
||||
throw new QingLong3PhysicalIdleEvidenceError(
|
||||
'expectedExecutable must be canonical',
|
||||
);
|
||||
}
|
||||
const sampleCount =
|
||||
(manifest.durationSeconds * 1000) / manifest.sampleIntervalMs + 1;
|
||||
const snapshots = [];
|
||||
for (let index = 0; index < sampleCount; index += 1) {
|
||||
if (index > 0) await delay(manifest.sampleIntervalMs);
|
||||
snapshots.push(readProcessSnapshot(manifest.processId));
|
||||
}
|
||||
return Object.freeze(snapshots);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (Number(process.versions.node.split('.')[0]) < 24) {
|
||||
throw new QingLong3PhysicalIdleEvidenceError(
|
||||
'Node.js 24 or newer is required',
|
||||
);
|
||||
}
|
||||
const options = parseArguments(process.argv.slice(2));
|
||||
const manifest = readManifest(options.manifestPath);
|
||||
const snapshots = await collectSnapshots(manifest);
|
||||
const report = buildIdleEvidenceReport({
|
||||
manifest,
|
||||
snapshots,
|
||||
generatedAt: new Date().toISOString(),
|
||||
});
|
||||
const serialized = `${JSON.stringify(report, null, options.json ? 0 : 2)}\n`;
|
||||
writeNoReplace(options.outputPath, serialized);
|
||||
process.stdout.write(serialized);
|
||||
if (!report.qualification.passed) process.exitCode = 1;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
QingLong3PhysicalIdleEvidenceError,
|
||||
buildIdleEvidenceReport,
|
||||
normalizeIdleManifest,
|
||||
parseArguments,
|
||||
parseProcIo,
|
||||
parseProcStat,
|
||||
parseProcStatus,
|
||||
summarizeSnapshots,
|
||||
validateSnapshotSeries,
|
||||
};
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch((error) => {
|
||||
process.stderr.write(
|
||||
`${error instanceof Error ? error.message : String(error)}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user