Files
ECC/tests/lib/helpers/state-store-worker.js
T

242 lines
9.5 KiB
JavaScript

'use strict';
const fs = require('fs');
const os = require('os');
const path = require('path');
const { fork } = require('child_process');
// These utilities belong only to the two process-based regression fixtures.
async function withCleanup(callback, cleanups) {
let failed = false;
let primary;
let value;
try { value = await callback(); }
catch (error) { failed = true; primary = error; }
for (const cleanup of cleanups) {
try { await cleanup(); }
catch (error) { if (!failed) { failed = true; primary = error; } }
}
if (failed) throw primary;
return value;
}
function startOwnedChild(script, args = [], dependencies = {}) {
const forkChild = dependencies.fork || fork;
const platform = dependencies.platform || process.platform;
const kill = dependencies.kill || process.kill.bind(process);
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-state-child-'));
const home = path.join(root, 'home');
const tmp = path.join(root, 'tmp');
let child;
try {
fs.mkdirSync(home); fs.mkdirSync(tmp);
// Absolute fixture paths resolve dependencies from the checkout, even with
// an isolated cwd. No package-private metadata or NODE_PATH is needed.
child = forkChild(script, args, {
execPath: process.execPath, execArgv: [], shell: false, detached: platform !== 'win32',
cwd: root, stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
env: { PATH: path.dirname(process.execPath),
HOME: home, USERPROFILE: home, APPDATA: home, LOCALAPPDATA: home,
TMPDIR: tmp, TMP: tmp, TEMP: tmp, NODE_USE_ENV_PROXY: '0' },
});
} catch (error) {
try { fs.rmSync(root, { recursive: true, force: true }); } catch (_cleanupError) { /* Primary wins. */ }
throw error;
}
const observers = new Set();
const waiters = new Set();
const closeWaiters = new Set();
let ownedPid = null;
let closed = false;
let ipcClosed = false;
const closedStreams = new Set();
let reaped = false;
let exitCode = null;
let exitSignal = null;
let failed = false;
let failure;
let stderr = '';
let captured = 0;
let stopPromise;
const fail = error => {
if (!failed) { failed = true; failure = error; }
for (const waiter of [...waiters]) waiter.reject(error);
};
const markClosed = () => {
closed = true;
for (const done of [...closeWaiters]) done(true);
};
// Some Node versions omit the aggregate close event after a parent-initiated
// IPC disconnect. Require public confirmation of every owned resource instead.
const checkClosed = () => {
if (reaped && ipcClosed && closedStreams.size === 2) markClosed();
};
child.once('disconnect', () => { ipcClosed = true; checkClosed(); });
child.once('spawn', () => { if (Number.isInteger(child.pid) && child.pid > 0) ownedPid = child.pid; });
child.on('error', fail);
child.once('exit', (code, signal) => {
reaped = true; exitCode = code; exitSignal = signal;
const error = new Error(`Fixture exited (${code}, ${signal || 'no signal'}): ${stderr}`);
for (const waiter of [...waiters]) waiter.reject(error);
checkClosed();
});
child.once('close', markClosed);
child.on('message', message => {
for (const observer of [...observers]) {
try { observer(message); } catch (error) { fail(error); }
}
for (const waiter of [...waiters]) waiter.check(message);
});
for (const stream of [child.stdout, child.stderr]) {
stream.once('close', () => { closedStreams.add(stream); checkClosed(); });
stream.on('data', data => {
captured += data.length;
if (captured > 128 * 1024) { fail(new Error('Fixture output exceeded 128 KiB')); return; }
if (stream === child.stderr) stderr += data.toString();
});
stream.on('error', fail);
}
function waitClosed(milliseconds) {
if (closed) return Promise.resolve(true);
return new Promise(resolve => {
let timer;
const done = value => { clearTimeout(timer); closeWaiters.delete(done); resolve(value); };
closeWaiters.add(done);
timer = setTimeout(() => done(false), milliseconds);
});
}
function signalOwned(signal) {
if (ownedPid === null) return;
try {
if (platform === 'win32') { if (!reaped) child.kill(signal); }
else kill(-ownedPid, signal);
} catch (error) { if (!error || error.code !== 'ESRCH') throw error; }
}
const client = {
child,
get diagnostics() { return { closed, reaped, captured, failed }; },
observe(callback) { observers.add(callback); return () => observers.delete(callback); },
waitFor(predicate, timeoutMs = 10000) {
if (failed || reaped) return Promise.reject(failed ? failure : new Error('Fixture already exited'));
return new Promise((resolve, reject) => {
let timer;
const finish = (fn, value) => { clearTimeout(timer); waiters.delete(waiter); fn(value); };
const waiter = {
check(message) {
try { if (predicate(message)) finish(resolve, message); }
catch (error) { finish(reject, error); }
},
reject: error => finish(reject, error),
};
waiters.add(waiter);
timer = setTimeout(() => waiter.reject(new Error(`Fixture response timed out: ${stderr}`)), timeoutMs);
});
},
async completion() {
if (!await waitClosed(10000)) throw new Error('Fixture completion timed out');
if (failed) throw failure;
return { status: exitCode, signal: exitSignal, stderr };
},
send(message) {
try {
if (!child.connected) throw new Error('Fixture IPC disconnected');
child.send(message, error => { if (error) fail(error); });
} catch (error) { fail(error); }
},
stop() {
if (stopPromise) return stopPromise;
stopPromise = withCleanup(async () => {
// Disconnect asks the fixture to close normally. If it cannot, escalate
// only this newly spawned child/group; retain the deadline through close.
if (child.connected) {
try { child.disconnect(); } catch (error) { fail(error); }
}
if (await waitClosed(300)) {
if (reaped && (exitCode !== 0 || exitSignal !== null)) {
throw new Error(`Fixture closed unsuccessfully (${exitCode}, ${exitSignal || 'no signal'}): ${stderr}`);
}
return;
}
let signalFailed = false;
let signalError;
try { signalOwned('SIGTERM'); } catch (error) { signalFailed = true; signalError = error; }
await new Promise(resolve => setTimeout(resolve, 300));
try { signalOwned('SIGKILL'); } catch (error) { if (!signalFailed) { signalFailed = true; signalError = error; } }
if (!await waitClosed(1000)) {
return withCleanup(() => {
if (signalFailed) throw signalError;
throw new Error('Fixture did not close after termination; pipes forcibly closed');
}, [() => child.stdout.destroy(), () => child.stderr.destroy(), () => waitClosed(100)]);
}
if (signalFailed) throw signalError;
}, [() => {
for (const waiter of [...waiters]) waiter.reject(new Error('Fixture stopped'));
observers.clear();
fs.rmSync(root, { recursive: true, force: true });
}]).then(() => {
// A successful close does not erase an earlier IPC/stream/disconnect
// failure. Report it only after every owned cleanup stage was attempted.
if (failed) throw failure;
}, cleanupError => {
if (failed) throw failure;
throw cleanupError;
});
return stopPromise;
},
};
return client;
}
async function withOwnedChildren(count, start, callback) {
const children = [];
return withCleanup(async () => {
for (let index = 0; index < count; index += 1) children.push(await start(index));
return callback(children);
}, [() => withCleanup(() => {}, children.map(child => () => child.stop()))]);
}
module.exports = { startOwnedChild, withOwnedChildren, withCleanup };
function runWorker() {
const { createStateStore } = require('../../../scripts/lib/state-store');
process.exitCode = 1;
let store;
process.on('message', async ({ id, action, dbPath, worker, count = 1 }) => {
try {
if (action === 'open') {
store = await createStateStore({ dbPath });
} else if (action === 'write') {
for (let index = 0; index < count; index += 1) {
store.upsertWorkItem({
id: `${worker}-${index}`, source: 'manual', title: `Task ${worker}-${index}`, status: 'open'
});
}
} else if (action === 'increment') {
for (let index = 0; index < count; index += 1) {
store._database.transaction(() => {
const item = store.getWorkItemById('counter');
// Widen real transaction overlap without assuming which worker wins.
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 15);
store.upsertWorkItem({ ...item, metadata: { value: item.metadata.value + 1 } });
})();
}
} else if (action === 'close') {
store.close();
store = null;
} else {
throw new Error(`Unknown fixture action: ${action}`);
}
if (process.connected) process.send({ id, ok: true });
} catch (error) {
if (process.connected) process.send({ id, ok: false, error: error && error.stack ? String(error.stack).slice(0, 4096) : String(error).slice(0, 4096) });
}
});
process.on('disconnect', () => {
try { if (store) store.close(); process.exitCode = 0; }
catch (error) { console.error(error); process.exitCode = 1; }
});
process.send({ ready: true });
}
if (require.main === module) runWorker();