404 lines
24 KiB
JavaScript
404 lines
24 KiB
JavaScript
'use strict';
|
|
const v = require('./validation');
|
|
// Network/filesystem are injected so publication, retries and login deadlines can be tested offline.
|
|
function create(builtin, io, sha256, options = {}) {
|
|
const perf = io.perf || require('./perf').create();
|
|
v.builtin(builtin);
|
|
const environment = options.environment || 'production';
|
|
const warn = error => io.warn && io.warn(error);
|
|
const now = () => io.now();
|
|
const sleep = ms => io.sleep ? io.sleep(ms) : new Promise(resolve => setTimeout(resolve, ms));
|
|
const readState = key => { try { return perf.measure('state.read.' + key.split(':')[0], () => io.readState(key)); } catch (_) { return null; } };
|
|
const save = (key, value) => { try { perf.measure('state.write.' + key.split(':')[0], () => io.writeState(key, value)); } catch (e) { warn(e); } };
|
|
let current = null;
|
|
try { current = v.manifest(readState('manifest'), builtin.contentVersion, environment); } catch (_) {}
|
|
let index = readState('index') || {};
|
|
if (!index || typeof index !== 'object' || Array.isArray(index)) index = {};
|
|
let epoch = 0, started = 0, refreshed = false, refreshing = null, limits = null;
|
|
let active = true, online = true, nextId = 1, mainGroup = 'A', protectAll = false;
|
|
let allReadyRelease = null, playing = null;
|
|
let cacheRevision = 0, maintainedRevision = -1, maintainedManifest = null, maintainedAt = -Infinity, collecting = null, allDemand = false;
|
|
const holds = new Set(), waiters = new Set();
|
|
const entryWaiters = new Set();
|
|
const entryError = code => Object.assign(Error('关卡读取失败,请连接网络!'), { code });
|
|
// Only the caller's foreground wait is cancelled. Shared downloads may still populate cache.
|
|
function waitForEntry(task) {
|
|
if (!online) return Promise.reject(entryError('LEVEL_OFFLINE'));
|
|
let timer, fail;
|
|
return new Promise((resolve, reject) => {
|
|
fail = error => reject(error);
|
|
entryWaiters.add(fail);
|
|
timer = setTimeout(() => fail(entryError('LEVEL_TIMEOUT')), options.entryTimeoutMs || 60000);
|
|
Promise.resolve().then(task).then(resolve, reject);
|
|
}).finally(() => { clearTimeout(timer); entryWaiters.delete(fail); });
|
|
}
|
|
const work = new Map(), downloads = new Map(), packs = new Map(), pins = new Map();
|
|
const prefetching = new Map();
|
|
let lastPrefetch = null;
|
|
const cancelled = () => Error('关卡准备已取消');
|
|
function guard(token) { if (token !== epoch) throw cancelled(); }
|
|
function wake() { for (const resolve of waiters) resolve(); waiters.clear(); }
|
|
function holdBackground() {
|
|
const hold = {}; holds.add(hold); perf.emit('background.hold', { count: holds.size });
|
|
return () => { if (holds.delete(hold)) { perf.emit('background.release', { count: holds.size }); wake(); } };
|
|
}
|
|
async function checkpoint(token, background = () => false) {
|
|
guard(token);
|
|
while (!active || !online || (holds.size && background())) { await new Promise(resolve => waiters.add(resolve)); guard(token); }
|
|
}
|
|
function retry(key, task) {
|
|
const token = epoch, fullKey = token + ':' + key;
|
|
if (work.has(fullKey)) return work.get(fullKey);
|
|
const job = (async () => {
|
|
let attempt = 0;
|
|
for (;;) {
|
|
await checkpoint(token);
|
|
try { const result = await task(); guard(token); return result; }
|
|
catch (error) {
|
|
guard(token); warn(error);
|
|
perf.emit('retry', { task: key, attempt: attempt + 1, delayMs: [2000, 5000, 10000, 30000, 60000][Math.min(attempt, 4)] });
|
|
await sleep([2000, 5000, 10000, 30000, 60000][Math.min(attempt++, 4)]);
|
|
}
|
|
}
|
|
})().finally(() => work.delete(fullKey));
|
|
work.set(fullKey, job); job.catch(() => {}); return job;
|
|
}
|
|
function background(key, task) { retry(key, task).catch(warn); }
|
|
function refresh() {
|
|
if (refreshed) return Promise.resolve(current);
|
|
if (refreshing) return refreshing;
|
|
const token = epoch;
|
|
const finish = perf.begin('manifest.refresh', { epoch: token });
|
|
const job = (async () => {
|
|
await checkpoint(token);
|
|
const response = await io.fetchManifest();
|
|
const m = perf.measure('manifest.validate', () => v.manifest(response, builtin.contentVersion, environment));
|
|
guard(token); current = m; refreshed = true; allReadyRelease = null; save('manifest', m);
|
|
perf.emit('manifest.ready', { release: m.release, A: m.groups.A.resourceCount, B: m.groups.B.resourceCount });
|
|
return m;
|
|
})().then(value => { finish({ status: 'ok' }); return value; }, error => { finish({ status: 'error' }); throw error; })
|
|
.finally(() => { if (refreshing === job) refreshing = null; });
|
|
refreshing = job; return job;
|
|
}
|
|
function beginLogin(data = {}) {
|
|
for (const fail of entryWaiters) fail(cancelled());
|
|
perf.session({ version: data.version, environment, cachedRelease: current && current.release });
|
|
epoch++; holds.clear(); wake(); prefetching.clear(); lastPrefetch = null; allDemand = false; collecting = null; maintainedManifest = null; refreshing = null; refreshed = false; started = now(); allReadyRelease = null; protectAll = false; playing = null;
|
|
const key = 'policy:' + String(data.version || 'unknown');
|
|
const old = readState(key) || {};
|
|
const validMax = n => v.integer(n, 100);
|
|
const maxLevel = validMax(data.maxLevel) ? data.maxLevel : validMax(old.maxLevel) ? old.maxLevel : options.defaultMaxLevel || 2040;
|
|
const validB = n => v.integer(n) && n <= maxLevel;
|
|
const bLevelCount = validB(data.bLevelCount) ? data.bLevelCount : validB(old.bLevelCount) ? old.bLevelCount : 0;
|
|
// Group assignment is never inferred from the retired new1_50 experiment or from N alone.
|
|
mainGroup = data.levelGroup === 'B' && bLevelCount > 0 ? 'B' : 'A';
|
|
limits = { maxLevel, bLevelCount, levelGroup: mainGroup };
|
|
perf.emit('policy', limits);
|
|
save(key, { maxLevel, bLevelCount });
|
|
background('manifest', refresh);
|
|
return Object.assign({}, limits);
|
|
}
|
|
function groupFor(id, group = mainGroup) { return group === 'B' && limits && id <= limits.bLevelCount ? 'B' : 'A'; }
|
|
function parse(bytes, entry) {
|
|
v.entry(entry);
|
|
v.check(bytes && bytes.byteLength === entry.size && perf.measure('json.sha256', () => sha256(new Uint8Array(bytes)), bytes.byteLength) === entry.hash, '关卡文件校验失败');
|
|
const text = perf.measure('json.decode', () => io.decode(bytes), bytes.byteLength);
|
|
return perf.measure('json.parseValidate', () => v.level(JSON.parse(text)), bytes.byteLength);
|
|
}
|
|
async function read(g, id, e) {
|
|
if (!e) return null;
|
|
try {
|
|
const local = builtin.groups[g][id];
|
|
if (local && local.hash === e.hash) {
|
|
try {
|
|
const bytes = await perf.measureAsync('file.readBuiltin', () => io.readLocal(local.file), e.size);
|
|
const data = parse(bytes, e); perf.metric('source.builtin', 0);
|
|
return { data, group: g, id, hash: e.hash };
|
|
} catch (_) { perf.metric('source.builtinInvalid', 0); }
|
|
}
|
|
if (io.hasCache && io.hasCache(g, id, e.hash) === false) { perf.metric('source.absentSkipped', 0); return null; }
|
|
const bytes = await perf.measureAsync('file.readCache', () => io.readCache(g, id, e.hash), e.size);
|
|
const data = parse(bytes, e); perf.metric('source.cache', 0);
|
|
return { data, group: g, id, hash: e.hash };
|
|
} catch (_) { perf.metric('source.missingOrInvalid', 0); return null; }
|
|
}
|
|
async function available(g, id, m = current, skipHash = null) {
|
|
const target = m && m.groups[g].levels[id];
|
|
const old = index[g + '/' + id];
|
|
const seen = new Set(skipHash ? [skipHash] : []);
|
|
for (const e of [target, old && old.entry, builtin.groups[g][id]]) {
|
|
if (!e || seen.has(e.hash)) continue;
|
|
seen.add(e.hash);
|
|
const data = await read(g, id, e);
|
|
if (data) {
|
|
if (old && e === old.entry) old.used = now();
|
|
if (target && data.hash !== target.hash) perf.metric('source.oldFallback', 0);
|
|
return data;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
function remember(g, id, e) {
|
|
const key = g + '/' + id, old = index[key];
|
|
index[key] = { entry: e, used: now() };
|
|
const changed = !old || !old.entry || old.entry.hash !== e.hash;
|
|
if (changed) cacheRevision++;
|
|
return changed;
|
|
}
|
|
function single(m, g, id, foreground = true, demanded = () => false) {
|
|
const e = m.groups[g].levels[id];
|
|
if (!e) return Promise.reject(Error('当前资源清单缺少关卡 ' + g + '/' + id));
|
|
const token = epoch, key = token + ':' + g + '/' + id + '/' + e.hash;
|
|
if (downloads.has(key)) {
|
|
const existing = downloads.get(key);
|
|
const previousDemand = existing.demanded;
|
|
existing.demanded = () => previousDemand() || demanded();
|
|
if (foreground) { existing.foreground = true; wake(); }
|
|
else if (demanded()) wake();
|
|
return existing.promise;
|
|
}
|
|
const job = { foreground, demanded, promise: null }, isBackground = () => !job.foreground && !job.demanded();
|
|
const task = (async () => {
|
|
await checkpoint(token, isBackground);
|
|
if (await read(g, id, e)) { guard(token); if (remember(g, id, e)) save('index', index); return; }
|
|
await checkpoint(token, isBackground);
|
|
const bytes = await io.fetchFile(Object.assign({ file: v.levelFile(g, id, e) }, e));
|
|
parse(bytes, e); guard(token);
|
|
await checkpoint(token, isBackground);
|
|
await io.writeCache(g, id, e.hash, bytes); guard(token);
|
|
remember(g, id, e); save('index', index);
|
|
})().finally(() => downloads.delete(key));
|
|
job.promise = task; downloads.set(key, job); return task;
|
|
}
|
|
function repair(m, g, id) { if (m && m.groups[g].levels[id]) background('level:' + g + '/' + id + '/' + m.groups[g].levels[id].hash, () => single(m, g, id, false)); }
|
|
async function load(id, group = mainGroup) {
|
|
v.check(limits && v.integer(id, 1) && id <= limits.maxLevel, '关卡超出登录开放范围');
|
|
const token = epoch, g = groupFor(id, group), pin = g + '/' + id; pins.set(pin, (pins.get(pin) || 0) + 1);
|
|
const finish = perf.begin('level.load', { id, group: g, epoch: token });
|
|
let status = 'error', source = 'download';
|
|
try {
|
|
let data = await available(g, id);
|
|
guard(token);
|
|
if (data) {
|
|
playing = pin;
|
|
const target = current && current.groups[g].levels[id];
|
|
if (target && data.hash !== target.hash) repair(current, g, id);
|
|
status = 'ok'; source = 'local'; return data;
|
|
}
|
|
data = await waitForEntry(async () => {
|
|
const m = current || await refresh();
|
|
await single(m, g, id); guard(token);
|
|
return read(g, id, m.groups[g].levels[id]);
|
|
});
|
|
guard(token); v.check(data, '关卡缓存不可用'); playing = pin; status = 'ok'; return data;
|
|
} finally { finish({ status, source }); const count = pins.get(pin) - 1; if (count) pins.set(pin, count); else pins.delete(pin); }
|
|
}
|
|
function indexed(g, id, entry) {
|
|
if (!entry) return false;
|
|
const local = builtin.groups[g][id];
|
|
// The runtime inventory comes from committed package catalogs / standalone filenames.
|
|
// Never treat the logical usage index alone as proof that a file is installed.
|
|
return !!((local && local.hash === entry.hash) || (io.hasCache && io.hasCache(g, id, entry.hash)));
|
|
}
|
|
async function ensurePack(m, g, name, allowOld = true) {
|
|
const p = m.groups[g].packs[name];
|
|
if (!p) throw Error('关卡包尚未发布:' + g + '/' + name);
|
|
const token = epoch, key = token + ':' + g + '/' + name + '/' + p.hash + '/' + allowOld;
|
|
if (packs.has(key)) return packs.get(key);
|
|
const finish = perf.begin('pack.ensure', { group: g, pack: name, count: p.end - p.start + 1 });
|
|
const isBackground = () => !(name === 'all' && allDemand);
|
|
const task = (async () => {
|
|
const scanStarted = now();
|
|
const missing = [];
|
|
for (let id = p.start; id <= p.end; id++) {
|
|
await checkpoint(token, isBackground);
|
|
const e = m.groups[g].levels[id];
|
|
if (!indexed(g, id, e)) {
|
|
const cached = index[g + '/' + id];
|
|
const old = allowOld && (indexed(g, id, cached && cached.entry) || indexed(g, id, builtin.groups[g][id]));
|
|
if (old) repair(m, g, id); else missing.push(id);
|
|
}
|
|
if (id % 16 === 0) await sleep(0);
|
|
}
|
|
guard(token);
|
|
perf.emit('pack.scan', { group: g, pack: name, mode: 'metadata', ms: now() - scanStarted, scanned: p.end - p.start + 1, missing: missing.length });
|
|
if (!missing.length) return;
|
|
const bytesMissing = missing.reduce((sum, id) => sum + m.groups[g].levels[id].size, 0);
|
|
perf.emit('pack.strategy', { group: g, pack: name, strategy: missing.length > 4 && bytesMissing >= p.size ? 'zip' : 'single', bytesMissing, zipBytes: p.size });
|
|
if (missing.length > 4 && bytesMissing >= p.size) {
|
|
await checkpoint(token, isBackground);
|
|
const archive = await io.fetchPack(m, g, Object.assign({ file: v.packFile(g, name, p) }, p), () => checkpoint(token, isBackground));
|
|
const installed = perf.begin('zip.install', { group: g, pack: name, missing: missing.length });
|
|
let count = 0, status = 'error';
|
|
try {
|
|
await checkpoint(token, isBackground);
|
|
await archive.install(); guard(token);
|
|
for (const id of missing) {
|
|
// Per-level hashes come from the manifest belonging to the verified ZIP.
|
|
remember(g, id, m.groups[g].levels[id]); count++;
|
|
}
|
|
status = 'ok';
|
|
} finally {
|
|
// The directory and its lookup metadata commit together before the logical index.
|
|
try { if (token === epoch && count) save('index', index); }
|
|
finally { try { await archive.dispose(); } finally { installed({ status, installed: count }); } }
|
|
}
|
|
} else {
|
|
for (let i = 0; i < missing.length; i += 3) {
|
|
await checkpoint(token, isBackground);
|
|
await Promise.all(missing.slice(i, i + 3).map(id => single(m, g, id, false, () => !isBackground())));
|
|
}
|
|
}
|
|
})().then(value => { finish({ status: 'ok' }); return value; }, error => { finish({ status: 'error' }); throw error; }).finally(() => packs.delete(key));
|
|
packs.set(key, task); return task;
|
|
}
|
|
function ensureAll(foreground = false) {
|
|
protectAll = true;
|
|
if (allReady()) return Promise.resolve(true);
|
|
if (foreground) { allDemand = true; wake(); }
|
|
return retry('all', async () => {
|
|
const token = epoch;
|
|
const m = refreshed ? current : await refresh().catch(error => { if (current) return current; throw error; });
|
|
await maintain(); guard(token);
|
|
await ensurePack(m, 'A', 'all'); guard(token);
|
|
// A later successful login manifest supersedes an offline snapshot while this task was running.
|
|
if (m !== current) throw Error('资源清单已更新,继续准备完整关卡池');
|
|
allReadyRelease = m.release;
|
|
await maintain(true); allDemand = false; return true;
|
|
});
|
|
}
|
|
function allReady() { return !!(current && allReadyRelease === current.release); }
|
|
function cacheIdentity(file) {
|
|
const match = /^([AB])\/([1-9][0-9]*)\.([a-f0-9]{64})\.json$/.exec(file);
|
|
return match && { group: match[1], id: Number(match[2]), hash: match[3], key: match[1] + '/' + match[2] };
|
|
}
|
|
function keepFile(file) {
|
|
const f = cacheIdentity(file); if (!f) return false;
|
|
const target = current && current.groups[f.group].levels[f.id], old = index[f.key];
|
|
return !!((target && target.hash === f.hash) || (old && old.entry && old.entry.hash === f.hash));
|
|
}
|
|
function protectFile(file) {
|
|
const f = cacheIdentity(file); if (!f) return false;
|
|
return (protectAll && f.group === 'A' && keepFile(file)) || playing === f.key || pins.has(f.key)
|
|
|| (f.id >= nextId && f.id <= nextId + 12 && f.group === groupFor(f.id));
|
|
}
|
|
function maintain(force = false) {
|
|
if (!io.collect || !current) return Promise.resolve();
|
|
if (collecting) return collecting;
|
|
if (!force && maintainedManifest === current && maintainedRevision === cacheRevision && now() - maintainedAt < 10000) {
|
|
perf.metric('cache.maintenanceSkipped', 0); return Promise.resolve();
|
|
}
|
|
const token = epoch;
|
|
const task = (async () => {
|
|
await checkpoint(token, () => !allDemand);
|
|
const finish = perf.begin('cache.maintain', { protectAll }), m = current, revision = cacheRevision;
|
|
try {
|
|
const buildStarted = now();
|
|
const files = io.listCache ? io.listCache() : Object.keys(index).filter(key => index[key] && index[key].entry)
|
|
.map(key => key + '.' + index[key].entry.hash + '.json');
|
|
const keep = new Set(), protect = new Set();
|
|
for (const file of files) { if (keepFile(file)) keep.add(file); if (protectFile(file)) protect.add(file); }
|
|
perf.emit('cache.plan', { files: files.length, ms: now() - buildStarted });
|
|
const collected = await io.collect(keep, protect, options.cacheBudgetBytes || 32 * 1024 * 1024,
|
|
{ checkpoint: () => checkpoint(token, () => !allDemand), keep: keepFile, protect: protectFile,
|
|
size: file => {
|
|
const f = cacheIdentity(file); if (!f) return null;
|
|
const entries = [current.groups[f.group].levels[f.id], index[f.key] && index[f.key].entry, builtin.groups[f.group][f.id]];
|
|
const entry = entries.find(e => e && e.hash === f.hash);
|
|
return entry ? entry.size : null;
|
|
} });
|
|
if (collected === false) return;
|
|
guard(token); maintainedManifest = m; maintainedRevision = revision; maintainedAt = now();
|
|
} finally { finish(); }
|
|
})().finally(() => { if (collecting === task) collecting = null; });
|
|
collecting = task; return task;
|
|
}
|
|
function prefetch(id, group = mainGroup) {
|
|
const key = epoch + ':' + group + '/' + id;
|
|
if (prefetching.has(key)) { perf.metric('prefetch.joined', 0); return prefetching.get(key); }
|
|
if (refreshed && lastPrefetch && lastPrefetch.key === key && lastPrefetch.manifest === current && lastPrefetch.revision === cacheRevision) {
|
|
perf.metric('prefetch.unchangedSkipped', 0); return Promise.resolve();
|
|
}
|
|
const token = epoch;
|
|
const task = prefetchOnce(id, group).then(snapshot => {
|
|
guard(token); lastPrefetch = Object.assign({ key }, snapshot);
|
|
}).finally(() => { if (prefetching.get(key) === task) prefetching.delete(key); });
|
|
prefetching.set(key, task); return task;
|
|
}
|
|
async function prefetchOnce(id, group) {
|
|
const finish = perf.begin('mainline.prefetch', { id, group });
|
|
try {
|
|
nextId = id;
|
|
const token = epoch, m = refreshed ? current : await refresh(); guard(token);
|
|
await checkpoint(token, () => true);
|
|
const snapshot = { manifest: m, revision: cacheRevision };
|
|
if (id > limits.maxLevel) { ensureAll().catch(warn); return snapshot; }
|
|
// Repair only locally owned unpassed mainline resources. Past levels are checked on real use.
|
|
const owned = new Set(Object.keys(index));
|
|
const scanStarted = now(); let checked = 0, repairs = 0;
|
|
for (const g of ['A', 'B']) for (const localId of Object.keys(builtin.groups[g])) owned.add(g + '/' + localId);
|
|
for (const key of owned) {
|
|
await checkpoint(token, () => true);
|
|
const [g, text] = key.split('/'), n = Number(text);
|
|
if (n >= id && n <= limits.maxLevel && g === groupFor(n, group) && m.groups[g].levels[n]) {
|
|
checked++;
|
|
// Repair discovery uses metadata, not a read/hash/parse of every owned JSON.
|
|
// Actual use (load/single) still verifies bytes and recovers missing/corrupt files.
|
|
const target = m.groups[g].levels[n], cached = index[key] && index[key].entry, local = builtin.groups[g][n];
|
|
if ((!cached || cached.hash !== target.hash) && (!local || local.hash !== target.hash)) {
|
|
repairs++; repair(m, g, n);
|
|
}
|
|
}
|
|
}
|
|
guard(token);
|
|
perf.emit('prefetch.scan', { id, owned: owned.size, checked, repairs, mode: 'metadata', ms: now() - scanStarted });
|
|
const g = groupFor(id, group), localCount = Object.keys(builtin.groups[g]).length;
|
|
if (id > localCount || id >= localCount - 10) {
|
|
const key = String(Math.floor((id - 1) / 100) * 100 + 1);
|
|
if (m.groups[g].packs[key]) background('pack:' + g + '/' + key, () => ensurePack(m, g, key));
|
|
}
|
|
// Resolve every look-ahead ID through N, including an A/B boundary or a partial built-in range.
|
|
for (let n = id; n <= Math.min(id + 11, limits.maxLevel); n++) {
|
|
const targetGroup = groupFor(n, group), start = Math.floor((n - 1) / 100) * 100 + 1;
|
|
if (m.groups[targetGroup].packs[start] && !builtin.groups[targetGroup][n])
|
|
background('pack:' + targetGroup + '/' + start, () => ensurePack(m, targetGroup, String(start)));
|
|
}
|
|
if (id >= limits.maxLevel - 10) ensureAll().catch(warn);
|
|
await maintain();
|
|
return snapshot;
|
|
} finally { finish(); }
|
|
}
|
|
async function prepareLogin(id, group = mainGroup) {
|
|
const finish = perf.begin('login.levelGate', { id, group, elapsedSinceLoginMs: now() - started });
|
|
let status = 'error', urgentReady = false;
|
|
try {
|
|
v.check(limits, '尚未收到登录关卡配置'); nextId = id;
|
|
const token = epoch, endless = id > limits.maxLevel;
|
|
const urgent = (async () => {
|
|
const m = await refresh(); guard(token);
|
|
if (endless) return ensureAll();
|
|
await Promise.all([id, id + 1, id + 2].filter(n => n <= limits.maxLevel).map(n => single(m, groupFor(n, group), n)));
|
|
})();
|
|
urgent.then(() => { urgentReady = true; }, warn);
|
|
background('mainline-prefetch', () => prefetch(id, group));
|
|
if (endless) ensureAll().catch(warn);
|
|
const remaining = Math.max(0, (options.loginBudgetMs === undefined ? 8000 : options.loginBudgetMs) - (now() - started));
|
|
await Promise.race([urgent.catch(() => sleep(remaining)), sleep(remaining)]); guard(token);
|
|
if (endless) { status = allReady() ? 'allReady' : 'homeReleasedPreparingAll'; return; }
|
|
const ready = await available(groupFor(id, group), id);
|
|
guard(token); v.check(ready, '当前关卡尚未准备好,请重试');
|
|
status = urgentReady ? 'urgentReady' : 'localFallback';
|
|
} finally { finish({ status, urgentReady }); perf.flush(); }
|
|
}
|
|
return { beginLogin, prepareLogin, load, prefetch, ensureAll, allReady, refresh, maintain, holdBackground,
|
|
limits: () => limits, groupFor,
|
|
setActive(value) { active = !!value; wake(); }, setOnline(value) {
|
|
online = !!value;
|
|
if (!online) for (const fail of entryWaiters) fail(entryError('LEVEL_OFFLINE'));
|
|
wake();
|
|
},
|
|
dispose() { epoch++; holds.clear(); for (const fail of entryWaiters) fail(cancelled()); wake(); active = true; online = true; }
|
|
};
|
|
}
|
|
module.exports = { create };
|