// --------------------------------------------------------------------------- // rp_db - a MySQL/MariaDB client written for this server. // // Speaks the MySQL client/server protocol directly over a TCP socket: framing, // handshake, mysql_native_password / caching_sha2_password (fast path), and // the text result-set protocol. No external packages - only node built-ins. // // Deliberate choices: // * CLIENT_MULTI_STATEMENTS is NOT negotiated, so a stacked-query injection // ("'; DROP TABLE ..") is rejected by the server even if escaping failed. // * CLIENT_LOCAL_FILES is NOT negotiated, so the server can never ask us to // read a local file. // * sql_mode is pinned at connect time, which also guarantees backslash // escaping semantics that format() relies on. // --------------------------------------------------------------------------- const net = require('net'); const crypto = require('crypto'); // -- capability flags -------------------------------------------------------- const CAP = { LONG_PASSWORD: 0x00000001, FOUND_ROWS: 0x00000002, LONG_FLAG: 0x00000004, CONNECT_WITH_DB: 0x00000008, LOCAL_FILES: 0x00000080, PROTOCOL_41: 0x00000200, TRANSACTIONS: 0x00002000, SECURE_CONNECTION: 0x00008000, MULTI_STATEMENTS: 0x00010000, MULTI_RESULTS: 0x00020000, PLUGIN_AUTH: 0x00080000, CONNECT_ATTRS: 0x00100000, PLUGIN_AUTH_LENENC: 0x00200000, }; const CLIENT_CAPS = CAP.LONG_PASSWORD | CAP.LONG_FLAG | CAP.CONNECT_WITH_DB | CAP.PROTOCOL_41 | CAP.TRANSACTIONS | CAP.SECURE_CONNECTION | CAP.PLUGIN_AUTH | CAP.PLUGIN_AUTH_LENENC; const COM = { QUIT: 0x01, QUERY: 0x03, PING: 0x0e }; // MySQL column type codes we care about when coercing text-protocol values. const T_INT = new Set([1, 2, 3, 8, 9, 13]); // TINY SHORT LONG LONGLONG INT24 YEAR const T_FLOAT = new Set([0, 4, 5, 246]); // DECIMAL FLOAT DOUBLE NEWDECIMAL // --------------------------------------------------------------------------- // Buffer helpers // --------------------------------------------------------------------------- class Reader { constructor(buf) { this.b = buf; this.o = 0; } u8() { return this.b[this.o++]; } u16() { const v = this.b.readUInt16LE(this.o); this.o += 2; return v; } u32() { const v = this.b.readUInt32LE(this.o); this.o += 4; return v; } skip(n) { this.o += n; return this; } bytes(n) { const v = this.b.subarray(this.o, this.o + n); this.o += n; return v; } nulStr() { let i = this.b.indexOf(0, this.o); if (i < 0) i = this.b.length; const s = this.b.toString('utf8', this.o, i); this.o = i + 1; return s; } // length-encoded integer; returns null for the 0xfb NULL marker lenenc() { const f = this.b[this.o++]; if (f < 0xfb) return f; if (f === 0xfb) return null; if (f === 0xfc) { const v = this.b.readUInt16LE(this.o); this.o += 2; return v; } if (f === 0xfd) { const v = this.b.readUIntLE(this.o, 3); this.o += 3; return v; } const v = this.b.readBigUInt64LE(this.o); this.o += 8; return Number(v); } lenencStr() { const n = this.lenenc(); if (n === null) return null; return this.bytes(n).toString('utf8'); } remaining() { return this.b.length - this.o; } } class Writer { constructor() { this.parts = []; } u8(v) { const b = Buffer.alloc(1); b[0] = v; this.parts.push(b); return this; } u16(v) { const b = Buffer.alloc(2); b.writeUInt16LE(v); this.parts.push(b); return this; } u32(v) { const b = Buffer.alloc(4); b.writeUInt32LE(v >>> 0); this.parts.push(b); return this; } zeros(n) { this.parts.push(Buffer.alloc(n)); return this; } raw(b) { this.parts.push(b); return this; } nulStr(s) { this.parts.push(Buffer.from(s, 'utf8')); return this.u8(0); } lenencBuf(b) { if (b.length < 0xfb) this.u8(b.length); else if (b.length < 0x10000) { this.u8(0xfc).u16(b.length); } else { const t = Buffer.alloc(4); t.writeUIntLE(b.length, 0, 3); this.u8(0xfd).raw(t.subarray(0, 3)); } return this.raw(b); } done() { return Buffer.concat(this.parts); } } // --------------------------------------------------------------------------- // Authentication // --------------------------------------------------------------------------- const sha1 = (d) => crypto.createHash('sha1').update(d).digest(); const sha256 = (d) => crypto.createHash('sha256').update(d).digest(); function xorBuf(a, b) { const out = Buffer.alloc(a.length); for (let i = 0; i < a.length; i++) out[i] = a[i] ^ b[i % b.length]; return out; } // SHA1(pw) XOR SHA1( scramble + SHA1(SHA1(pw)) ) function authNative(password, scramble) { if (!password) return Buffer.alloc(0); const s1 = sha1(Buffer.from(password, 'utf8')); const s2 = sha1(s1); return xorBuf(s1, sha1(Buffer.concat([scramble, s2]))); } // SHA256(pw) XOR SHA256( SHA256(SHA256(pw)) + scramble ) [fast path only] function authCachingSha2(password, scramble) { if (!password) return Buffer.alloc(0); const s1 = sha256(Buffer.from(password, 'utf8')); const s2 = sha256(s1); return xorBuf(s1, sha256(Buffer.concat([s2, scramble]))); } function authResponse(plugin, password, scramble) { switch (plugin) { case 'mysql_native_password': return authNative(password, scramble); case 'caching_sha2_password': return authCachingSha2(password, scramble); case 'mysql_clear_password': return Buffer.concat([Buffer.from(password, 'utf8'), Buffer.alloc(1)]); default: return null; // unsupported -> connection fails loudly } } // --------------------------------------------------------------------------- // Value escaping. Every query goes through format(); values are never // concatenated into SQL anywhere else in this codebase. // --------------------------------------------------------------------------- const ESCAPE_MAP = { '\0': '\\0', '\b': '\\b', '\t': '\\t', '\n': '\\n', '\r': '\\r', '\x1a': '\\Z', '"': '\\"', "'": "\\'", '\\': '\\\\', }; function escapeString(s) { return "'" + s.replace(/[\0\b\t\n\r\x1a"'\\]/g, (c) => ESCAPE_MAP[c]) + "'"; } function pad2(n) { return n < 10 ? '0' + n : '' + n; } function escapeValue(v) { if (v === null || v === undefined) return 'NULL'; switch (typeof v) { case 'boolean': return v ? '1' : '0'; case 'number': if (!Number.isFinite(v)) throw new Error('cannot bind non-finite number'); return String(v); case 'bigint': return String(v); case 'string': return escapeString(v); case 'object': if (v instanceof Date) { return escapeString( `${v.getFullYear()}-${pad2(v.getMonth() + 1)}-${pad2(v.getDate())} ` + `${pad2(v.getHours())}:${pad2(v.getMinutes())}:${pad2(v.getSeconds())}` ); } if (Buffer.isBuffer(v)) return 'x' + escapeString(v.toString('hex')); if (Array.isArray(v)) return v.map(escapeValue).join(', '); return escapeString(JSON.stringify(v)); // plain objects -> JSON columns default: throw new Error('cannot bind value of type ' + typeof v); } } // Replace `?` placeholders, skipping any that appear inside string/identifier // literals or comments so that user data containing '?' can never shift binding. function format(sql, params) { if (!params || (Array.isArray(params) && params.length === 0)) return sql; const list = Array.isArray(params) ? params : [params]; let out = ''; let pi = 0; let i = 0; const n = sql.length; while (i < n) { const c = sql[i]; if (c === "'" || c === '"' || c === '`') { const quote = c; out += c; i++; while (i < n) { if (sql[i] === '\\' && quote !== '`') { out += sql[i] + (sql[i + 1] || ''); i += 2; continue; } if (sql[i] === quote) { if (sql[i + 1] === quote) { out += quote + quote; i += 2; continue; } // doubled quote out += quote; i++; break; } out += sql[i]; i++; } continue; } if (c === '-' && sql[i + 1] === '-') { while (i < n && sql[i] !== '\n') out += sql[i++]; continue; } if (c === '#') { while (i < n && sql[i] !== '\n') out += sql[i++]; continue; } if (c === '/' && sql[i + 1] === '*') { out += '/*'; i += 2; while (i < n && !(sql[i] === '*' && sql[i + 1] === '/')) out += sql[i++]; out += '*/'; i += 2; continue; } if (c === '?') { if (pi >= list.length) throw new Error('not enough parameters for query'); out += escapeValue(list[pi++]); i++; continue; } out += c; i++; } if (pi !== list.length) throw new Error(`parameter count mismatch (query takes ${pi}, got ${list.length})`); return out; } // --------------------------------------------------------------------------- // Connection // --------------------------------------------------------------------------- const MAX_PAYLOAD = 0xffffff; class Connection { constructor(opts, id) { this.opts = opts; this.id = id; this.socket = null; this.buf = Buffer.alloc(0); this.seq = 0; this.state = 'offline'; this.busy = false; this.pinned = false; // reserved by a transaction; pump() must skip it this.pending = null; // {resolve, reject, sql} this.rs = null; // in-flight result set this.multi = null; // reassembly buffer for >16MB payloads this.serverCaps = 0; this.onIdle = null; this.retryDelay = 1000; } log(msg) { console.log(`^5[rp_db]^7 conn#${this.id} ${msg}`); } err(msg) { console.log(`^1[rp_db]^7 conn#${this.id} ${msg}`); } connect() { if (this.state !== 'offline') return; this.state = 'connecting'; this.buf = Buffer.alloc(0); this.seq = 0; const sock = net.createConnection({ host: this.opts.host, port: this.opts.port }); this.socket = sock; sock.setNoDelay(true); sock.on('data', (d) => { this.buf = this.buf.length ? Buffer.concat([this.buf, d]) : d; this.drainPackets(); }); sock.on('error', (e) => this.fail(e.message)); sock.on('close', () => { if (this.state !== 'offline') this.fail('connection closed'); }); } fail(reason) { const wasReady = this.state === 'ready' || this.state === 'query'; this.state = 'offline'; if (this.socket) { this.socket.destroy(); this.socket = null; } if (this.pending) { const p = this.pending; this.pending = null; this.busy = false; p.reject(new Error(reason)); } this.busy = false; this.pinned = false; // a dropped connection must not stay reserved this.rs = null; if (wasReady) this.err(`lost connection: ${reason}`); else this.err(`connect failed: ${reason}`); // exponential backoff, capped setTimeout(() => this.connect(), this.retryDelay); this.retryDelay = Math.min(this.retryDelay * 2, 30000); } // Split the stream into protocol packets, reassembling 16MB-split payloads. drainPackets() { for (;;) { if (this.buf.length < 4) return; const len = this.buf.readUIntLE(0, 3); if (this.buf.length < 4 + len) return; const seq = this.buf[3]; const payload = this.buf.subarray(4, 4 + len); this.buf = this.buf.subarray(4 + len); this.seq = (seq + 1) & 0xff; if (len === MAX_PAYLOAD) { // more to come this.multi = this.multi ? Buffer.concat([this.multi, payload]) : payload; continue; } let full = payload; if (this.multi) { full = Buffer.concat([this.multi, payload]); this.multi = null; } try { this.onPacket(full); } catch (e) { this.err(`packet handling error: ${e.message}`); this.fail(e.message); return; } } } send(payload) { if (!this.socket) return; const chunks = []; let off = 0; do { const slice = payload.subarray(off, off + MAX_PAYLOAD); const head = Buffer.alloc(4); head.writeUIntLE(slice.length, 0, 3); head[3] = this.seq & 0xff; this.seq = (this.seq + 1) & 0xff; chunks.push(head, slice); off += slice.length; } while (off < payload.length); this.socket.write(Buffer.concat(chunks)); } sendCommand(cmd, text) { this.seq = 0; // every command starts a new sequence const body = text ? Buffer.from(text, 'utf8') : Buffer.alloc(0); this.send(Buffer.concat([Buffer.from([cmd]), body])); } onPacket(p) { switch (this.state) { case 'connecting': return this.onHandshake(p); case 'auth': return this.onAuthResult(p); case 'init': return this.onInitResult(p); case 'query': return this.onQueryPacket(p); default: this.err(`unexpected packet in state ${this.state}`); } } // -- handshake ------------------------------------------------------------- onHandshake(p) { const r = new Reader(p); const protocol = r.u8(); if (protocol === 0xff) return this.fail(this.readError(p).message); if (protocol !== 10) return this.fail(`unsupported protocol version ${protocol}`); this.serverVersion = r.nulStr(); r.u32(); // connection id const scramble1 = r.bytes(8); r.skip(1); // filler let caps = r.u16(); let scramble2 = Buffer.alloc(0); let plugin = 'mysql_native_password'; if (r.remaining() > 0) { r.u8(); // charset r.u16(); // status flags caps |= r.u16() << 16; const authDataLen = r.u8(); r.skip(10); if (caps & CAP.SECURE_CONNECTION) { const n = Math.max(13, authDataLen - 8); scramble2 = r.bytes(n); // trailing NUL is part of the padding, not the scramble if (scramble2.length && scramble2[scramble2.length - 1] === 0) { scramble2 = scramble2.subarray(0, scramble2.length - 1); } } if (caps & CAP.PLUGIN_AUTH) plugin = r.nulStr(); } this.serverCaps = caps >>> 0; this.scramble = Buffer.concat([scramble1, scramble2]); const resp = authResponse(plugin, this.opts.password, this.scramble); if (resp === null) return this.fail(`server requested unsupported auth plugin '${plugin}'`); const w = new Writer(); w.u32(CLIENT_CAPS).u32(MAX_PAYLOAD).u8(45 /* utf8mb4_general_ci */).zeros(23); w.nulStr(this.opts.user); w.lenencBuf(resp); w.nulStr(this.opts.database); w.nulStr(plugin); this.authPlugin = plugin; this.state = 'auth'; this.send(w.done()); } onAuthResult(p) { const marker = p[0]; if (marker === 0x00) return this.afterAuth(); if (marker === 0xff) return this.fail(this.readError(p).message); if (marker === 0xfe) { // AuthSwitchRequest const r = new Reader(p); r.u8(); const plugin = r.nulStr(); let scramble = r.bytes(r.remaining()); if (scramble.length && scramble[scramble.length - 1] === 0) scramble = scramble.subarray(0, scramble.length - 1); const resp = authResponse(plugin, this.opts.password, scramble); if (resp === null) return this.fail(`server switched to unsupported auth plugin '${plugin}'`); this.scramble = scramble; this.authPlugin = plugin; return this.send(resp); } if (marker === 0x01) { // caching_sha2 extra auth data const status = p[1]; if (status === 0x03) return; // fast auth ok -> OK packet follows if (status === 0x04) { return this.fail( 'caching_sha2_password requires a full authentication exchange (TLS or the ' + 'server public key), which this driver does not implement. Give the database ' + "user mysql_native_password, e.g. ALTER USER .. IDENTIFIED VIA mysql_native_password." ); } return this.fail(`unexpected auth continuation 0x${status.toString(16)}`); } this.fail(`unexpected auth response 0x${marker.toString(16)}`); } // Pin session settings, then announce readiness. afterAuth() { this.retryDelay = 1000; this.state = 'init'; this.sendCommand( COM.QUERY, "SET NAMES utf8mb4, sql_mode='STRICT_TRANS_TABLES,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION', " + "time_zone='+00:00', autocommit=1" ); } onInitResult(p) { if (p[0] === 0xff) return this.fail(this.readError(p).message); this.state = 'ready'; this.busy = false; this.log(`ready (server ${this.serverVersion}, auth ${this.authPlugin})`); if (this.onIdle) this.onIdle(this); } // -- errors ---------------------------------------------------------------- readError(p) { const r = new Reader(p); r.u8(); const code = r.u16(); let sqlState = ''; if (p[3] === 0x23) { r.skip(1); sqlState = r.bytes(5).toString('ascii'); } const message = p.subarray(r.o).toString('utf8'); const e = new Error(`${message} (errno ${code}${sqlState ? ', sqlstate ' + sqlState : ''})`); e.errno = code; e.sqlState = sqlState; return e; } readOk(p) { const r = new Reader(p); r.u8(); const affectedRows = r.lenenc() || 0; const insertId = r.lenenc() || 0; return { affectedRows, insertId }; } isEof(p) { return p[0] === 0xfe && p.length < 9; } // -- queries --------------------------------------------------------------- query(sql) { return new Promise((resolve, reject) => { if (this.state !== 'ready') return reject(new Error('connection not ready')); this.busy = true; this.state = 'query'; this.pending = { resolve, reject, sql }; this.rs = { stage: 'head', columns: [], rows: [], colCount: 0 }; this.sendCommand(COM.QUERY, sql); }); } finish(value, error) { const p = this.pending; this.pending = null; this.rs = null; this.state = 'ready'; this.busy = false; if (p) { error ? p.reject(error) : p.resolve(value); } if (this.onIdle) this.onIdle(this); } onQueryPacket(p) { const rs = this.rs; if (rs.stage === 'head') { if (p[0] === 0x00) return this.finish(this.readOk(p)); if (p[0] === 0xff) return this.finish(null, this.readError(p)); if (p[0] === 0xfb) return this.finish(null, new Error('LOCAL INFILE requested but not enabled')); const r = new Reader(p); rs.colCount = r.lenenc(); rs.stage = 'columns'; return; } if (rs.stage === 'columns') { if (this.isEof(p)) { rs.stage = 'rows'; return; } const r = new Reader(p); r.lenencStr(); r.lenencStr(); r.lenencStr(); r.lenencStr(); // catalog, schema, table, org_table const name = r.lenencStr(); r.lenencStr(); // org_name r.lenenc(); // length of fixed fields r.u16(); // charset r.u32(); // column length const type = r.u8(); rs.columns.push({ name, type }); return; } // stage === 'rows' if (p[0] === 0xff) return this.finish(null, this.readError(p)); if (this.isEof(p)) return this.finish(rs.rows); const r = new Reader(p); const row = {}; for (let i = 0; i < rs.colCount; i++) { const col = rs.columns[i]; const raw = r.lenencStr(); row[col.name] = raw === null ? null : coerce(raw, col.type); } rs.rows.push(row); } ping() { if (this.state !== 'ready') return; this.busy = true; this.state = 'query'; this.pending = { resolve: () => {}, reject: (e) => this.err(`ping failed: ${e.message}`), sql: 'PING', }; this.rs = { stage: 'head', columns: [], rows: [], colCount: 0 }; this.sendCommand(COM.PING, ''); } } // Text protocol hands us strings; give Lua sensible types back. function coerce(raw, type) { if (T_INT.has(type)) { const n = Number(raw); return Number.isSafeInteger(n) ? n : raw; // keep huge BIGINTs exact as strings } if (T_FLOAT.has(type)) { const n = Number(raw); return Number.isNaN(n) ? raw : n; } return raw; // strings, dates, JSON, blobs } // --------------------------------------------------------------------------- // Pool // --------------------------------------------------------------------------- class Pool { constructor(opts) { this.opts = opts; this.queue = []; this.conns = []; for (let i = 0; i < opts.size; i++) { const c = new Connection(opts, i + 1); c.onIdle = () => this.pump(); this.conns.push(c); } } start() { this.conns.forEach((c, i) => setTimeout(() => c.connect(), i * 120)); setInterval(() => { for (const c of this.conns) if (c.state === 'ready' && !c.busy) c.ping(); }, 30000); } get ready() { return this.conns.some((c) => c.state === 'ready'); } free() { return this.conns.find((c) => c.state === 'ready' && !c.busy && !c.pinned); } pump() { while (this.queue.length) { const conn = this.free(); if (!conn) return; const job = this.queue.shift(); clearTimeout(job.timer); if (job.settled) continue; job.settled = true; conn.query(job.sql).then(job.resolve, job.reject); } } exec(sql) { return new Promise((resolve, reject) => { const job = { sql, resolve, reject, settled: false }; // Each job owns its own deadline, so a query still fails loudly when the // database is down and no connection ever becomes free to trigger pump(). job.timer = setTimeout(() => { if (job.settled) return; job.settled = true; const i = this.queue.indexOf(job); if (i >= 0) this.queue.splice(i, 1); reject(new Error('timed out waiting for a database connection')); }, this.opts.queueTimeout); this.queue.push(job); this.pump(); }); } // Pins one connection for the whole transaction so no other query can // interleave between START TRANSACTION and COMMIT. async transaction(statements) { const held = await this.acquire(); try { await held.query('START TRANSACTION'); for (const st of statements) await held.query(st); await held.query('COMMIT'); return true; } catch (e) { try { await held.query('ROLLBACK'); } catch (_) { /* connection already gone */ } throw e; } finally { held.release(); } } acquire() { return new Promise((resolve, reject) => { const started = Date.now(); const attempt = () => { const conn = this.free(); if (conn) { conn.pinned = true; return resolve({ query: (sql) => conn.query(sql), release: () => { conn.pinned = false; this.pump(); }, }); } if (Date.now() - started > this.opts.queueTimeout) { return reject(new Error('timed out waiting for a database connection')); } setTimeout(attempt, 25); }; attempt(); }); } } // --------------------------------------------------------------------------- // FiveM bridge - exposes the pool to the Lua resources as exports. // --------------------------------------------------------------------------- // Lua cannot put nil inside a table without creating a hole, so callers pass // this sentinel when they need a real SQL NULL. rp_db exposes it as DB.NULL. const NULL_SENTINEL = 'RP_NULL'; function unsentinel(v) { return v === NULL_SENTINEL ? null : v; } function normalizeParams(p) { if (p === null || p === undefined) return []; if (Array.isArray(p)) return p.map(unsentinel); if (typeof p === 'object') { const keys = Object.keys(p); if (keys.length === 0) return []; // Lua {} arrives as an empty map if (keys.every((k) => /^[0-9]+$/.test(k))) { // Lua array table return keys.sort((a, b) => a - b).map((k) => unsentinel(p[k])); } return [p]; // a real object -> JSON column } return [unsentinel(p)]; } const pool = new Pool({ host: GetConvar('rp_db_host', '127.0.0.1'), port: parseInt(GetConvar('rp_db_port', '3306'), 10), user: GetConvar('rp_db_user', 'rp'), password: GetConvar('rp_db_pass', ''), database: GetConvar('rp_db_name', 'rp'), size: parseInt(GetConvar('rp_db_pool', '4'), 10), queueTimeout: 20000, }); // Run a query and hand the result to a Lua callback as (result, err). // The SQL *template* is logged on failure, never the formatted text, so bound // values (password hashes and the like) stay out of the console. function run(sql, params, cb, shape) { const done = (result, err) => { if (err) { console.log(`^1[rp_db]^7 query failed: ${err}`); console.log(`^1[rp_db]^7 in: ${String(sql).trim().slice(0, 200)}`); } if (typeof cb === 'function') { try { cb(result, err || null); } catch (e) { console.log(`^1[rp_db]^7 callback threw: ${e.message}`); } } }; let text; try { text = format(sql, normalizeParams(params)); } catch (e) { return done(null, e.message); } pool.exec(text).then( (res) => { try { done(shape(res), null); } catch (e) { done(null, e.message); } }, (e) => done(null, e.message) ); } const rowsOf = (res) => (Array.isArray(res) ? res : []); global.exports('query', (sql, params, cb) => run(sql, params, cb, (r) => r)); global.exports('single', (sql, params, cb) => run(sql, params, cb, (r) => rowsOf(r)[0] || null)); global.exports('scalar', (sql, params, cb) => run(sql, params, cb, (r) => { const row = rowsOf(r)[0]; if (!row) return null; const k = Object.keys(row)[0]; return k === undefined ? null : row[k]; })); global.exports('insert', (sql, params, cb) => run(sql, params, cb, (r) => (r && r.insertId) || 0)); global.exports('update', (sql, params, cb) => run(sql, params, cb, (r) => (r && r.affectedRows) || 0)); // statements: array of {query = ..., values = {...}} tables from Lua global.exports('transaction', (statements, cb) => { let list; try { const arr = Array.isArray(statements) ? statements : Object.values(statements || {}); list = arr.map((s) => format(s.query, normalizeParams(s.values))); } catch (e) { console.log(`^1[rp_db]^7 transaction rejected: ${e.message}`); if (typeof cb === 'function') cb(false, e.message); return; } pool.transaction(list).then( () => { if (typeof cb === 'function') cb(true, null); }, (e) => { console.log(`^1[rp_db]^7 transaction rolled back: ${e.message}`); if (typeof cb === 'function') cb(false, e.message); } ); }); global.exports('isReady', () => pool.ready); global.exports('nullSentinel', () => NULL_SENTINEL); pool.start();