- __mysql.cpp: <sql> never checked mysql_real_query's own return code, so a genuine query failure was indistinguishable from "ran fine, zero rows" - <try>/<catch> around a failing <sql> never actually caught anything anywhere. Now throws on a real failure. - spa.js: dynamically re-injected scripts now run in true sequential order (wait for each external script's load event before running the next), fixing checkout's Square card field never building on the first client-side navigation to /checkout (only after a refresh) - async=false alone only orders multiple external scripts relative to each other, it doesn't delay a plain inline sibling. - /vst split into a real overview + /vst/modular1 subpage with a left menu, reusing opensource.page's existing subpage-tier pattern (opensource.menu/main.css's submenuitem-active highlighting, and several other subpage-tier retirements, were already finished earlier this session but never committed - included here too). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
273 lines
13 KiB
C++
273 lines
13 KiB
C++
#include "__mysql.h"
|
|
#include "Exception.h"
|
|
#include <iostream>
|
|
#include <string>
|
|
#include <unordered_map>
|
|
|
|
namespace jet {
|
|
|
|
namespace {
|
|
struct PooledConnection {
|
|
MYSQL *handle;
|
|
std::string host, user, database;
|
|
};
|
|
// Persistent, per-worker-thread connection cache - reused across
|
|
// requests instead of a fresh mysql_real_connect() every time.
|
|
// Safe with no locking: the apiserver's EPoll worker pool is a
|
|
// fixed set of OS threads for the process's whole life (see
|
|
// MysqlThreadScope's own doc comment - mysql_thread_init() runs
|
|
// once per worker thread at thread start), so a thread_local
|
|
// instance here means a given MYSQL* handle is only ever touched
|
|
// by the one thread that created it, never concurrently accessed.
|
|
// Keyed by sessionid, which is how this codebase's own <database>/
|
|
// <sql>/<ifrow> tags already address a specific connection
|
|
// (Global::sessions is keyed the same way) - the cached host/user/
|
|
// database are kept alongside purely as a defensive check that a
|
|
// reused entry really matches what THIS invocation asked for,
|
|
// not a real expectation that sessionid is ever reused across
|
|
// genuinely different backing databases in practice.
|
|
thread_local std::unordered_map<std::string, PooledConnection> connectionPool;
|
|
}
|
|
|
|
__mysql::__mysql(coreutils::ZString &in, coreutils::MString &parentOut, Global &global, Tag *parent, Tag *local) : Tag(in, parentOut, global, parent, this) {
|
|
|
|
if(!keywordDefined("host"))
|
|
throw coreutils::Exception("host must be specified for mysql tag.");
|
|
// jet-1.4's <database> spells the same attribute "name" instead of
|
|
// "database" (e.g. SESSIONCTL.tag's own <sessionctl> definition) -
|
|
// accept either rather than requiring that real content to change.
|
|
coreutils::ZString databaseKeyword = keywordDefined("database") ? "database" : "name";
|
|
if(!keywordDefined(databaseKeyword))
|
|
throw coreutils::Exception("database must be specified for mysql tag.");
|
|
if(!keywordDefined("user"))
|
|
throw coreutils::Exception("user must be specified for mysql tag.");
|
|
if(!keywordDefined("password"))
|
|
throw coreutils::Exception("password must be specified for mysql tag.");
|
|
|
|
// Same double-indirection bug as the host/user/password/database
|
|
// fix above - resolveKeyword("sessionid") already returns the
|
|
// resolved VALUE (e.g. "_SESSIONCTL"), so wrapping it in keywords[...]
|
|
// used that value as a map KEY instead, auto-vivifying an empty
|
|
// string (no attribute is ever literally named "_SESSIONCTL").
|
|
// sessionId was therefore always empty regardless of what
|
|
// sessionid= actually specified - confirmed live via gdb: two
|
|
// real <mysql>/<database> tags with genuinely different sessionid
|
|
// values ("" and "_SESSIONCTL") both registered under "", the
|
|
// second colliding with "sessionid already exists.".
|
|
sessionId = resolveKeyword("sessionid");
|
|
|
|
// mysql_real_connect() returns NULL on failure but does NOT itself
|
|
// free the handle mysql_init() allocated - the caller is still
|
|
// expected to mysql_close() that original handle to avoid leaking
|
|
// it (MySQL C API docs). The previous code overwrote `mysql` with
|
|
// the connect() return value directly, so on failure the only
|
|
// pointer to the real handle was lost before ever being freed - a
|
|
// real leak on every failed connection attempt (this member's
|
|
// eventual value, NULL, is what a normal ~__mysql() would try to
|
|
// mysql_close() anyway, which is a safe no-op, but that never even
|
|
// runs here since the constructor throws below - see the
|
|
// addSession() comment for why that matters more).
|
|
// resolveKeyword() already returns the fully-resolved VALUE - the
|
|
// previous keywords[resolveKeyword("host")] wrapped that resolved
|
|
// VALUE ("localhost") back into a keywords[] lookup, i.e. used it
|
|
// as a MAP KEY instead of the value itself. Since no attribute is
|
|
// ever literally named "localhost"/the real username/etc.,
|
|
// std::map::operator[] silently auto-vivified an empty MString for
|
|
// every one of the four parameters - mysql_real_connect() was
|
|
// therefore always called with host/user/password/database all
|
|
// empty, regardless of what the tag actually specified. Confirmed
|
|
// live: MySQL's own error ("Access denied for user 'barant'@'localhost'
|
|
// (using password: NO)") showed the password was genuinely empty,
|
|
// not merely wrong.
|
|
coreutils::MString hostVal = resolveKeyword("host");
|
|
coreutils::MString userVal = resolveKeyword("user");
|
|
coreutils::MString passwordVal = resolveKeyword("password");
|
|
coreutils::MString databaseVal = resolveKeyword(databaseKeyword);
|
|
|
|
// Reuse a live connection this same worker thread already holds
|
|
// for this sessionid, rather than paying mysql_real_connect()'s
|
|
// full handshake cost on every single request. mysql_ping() is
|
|
// what makes this safe - it's a genuine round trip to the server,
|
|
// so a connection MySQL itself already dropped (its own
|
|
// wait_timeout, or an admin-initiated restart like this project
|
|
// already lived through once) is detected and discarded here
|
|
// rather than handed to processContainer() to fail on later.
|
|
std::string poolKey = sessionId.str();
|
|
auto pooled = connectionPool.find(poolKey);
|
|
if(pooled != connectionPool.end() &&
|
|
pooled->second.host == hostVal.str() &&
|
|
pooled->second.user == userVal.str() &&
|
|
pooled->second.database == databaseVal.str()) {
|
|
if(mysql_ping(pooled->second.handle) == 0) {
|
|
mysql = pooled->second.handle;
|
|
} else {
|
|
mysql_close(pooled->second.handle);
|
|
connectionPool.erase(pooled);
|
|
}
|
|
}
|
|
|
|
if(!mysql) {
|
|
MYSQL *handle = mysql_init(NULL);
|
|
mysql = mysql_real_connect(handle,
|
|
hostVal.c_str(),
|
|
userVal.c_str(),
|
|
passwordVal.c_str(),
|
|
databaseVal.c_str(), 0, NULL, 0);
|
|
|
|
if(!mysql) {
|
|
std::string err = mysql_error(handle);
|
|
mysql_close(handle);
|
|
throw coreutils::Exception("database connection failed: " + err);
|
|
}
|
|
|
|
connectionPool[poolKey] = PooledConnection{mysql, hostVal.str(), userVal.str(), databaseVal.str()};
|
|
}
|
|
|
|
// Registered only once the connection is confirmed live - a real,
|
|
// reproducible crash (heap corruption - "free(): invalid pointer"
|
|
// seen live, then two independent server-hang repros) traced back
|
|
// to this: addSession() used to run BEFORE the connect attempt, so
|
|
// a FAILED connection (which throws, right above) left a dangling
|
|
// `this` pointer registered under sessionId in Global's session
|
|
// map forever - ~__mysql() (which would normally call
|
|
// removeSession()) never runs for a constructor that itself threw,
|
|
// per ordinary C++ rules (only already-completed base-class
|
|
// subobjects get destroyed during exception unwinding, not the
|
|
// derived object whose constructor is still executing). The very
|
|
// next <mysql> tag construction using the same sessionId (e.g. two
|
|
// product pages with an empty sessionid="" back to back, both
|
|
// failing the same way) then collided with that stale entry.
|
|
// Confirmed via a real, deterministic two-request repro before this
|
|
// fix, and confirmed gone after it.
|
|
global.addSession(sessionId, this);
|
|
|
|
processContainer(container);
|
|
|
|
}
|
|
|
|
__mysql::~__mysql() {
|
|
global.removeSession(sessionId);
|
|
mysql_free_result(result);
|
|
// mysql itself is deliberately NOT closed here anymore - see the
|
|
// connectionPool doc comment above the constructor. It's owned by
|
|
// this worker thread's persistent pool now and gets reused by
|
|
// whatever request this thread handles next, instead of being
|
|
// torn down and fully reconnected from scratch every time.
|
|
}
|
|
|
|
void __mysql::query(coreutils::MString query) {
|
|
int rc = mysql_real_query(mysql, query.getData(), query.getLength());
|
|
// rc was captured but never checked - a genuinely failed query
|
|
// (syntax error, constraint violation, etc.) fell straight into
|
|
// the result==NULL branch below, which is ALSO the normal path
|
|
// for a successful non-SELECT statement (INSERT/UPDATE/DELETE
|
|
// have no result set to store either). That made every SQL error
|
|
// in the whole codebase silently indistinguishable from "ran fine,
|
|
// no rows" - <try>/<catch> around a failing <sql> never caught
|
|
// anything, since no exception was ever thrown in the first place.
|
|
// Only rc (mysql_real_query's own success/failure signal) can
|
|
// actually tell the two cases apart; result's nullness can't.
|
|
if(rc != 0)
|
|
throw coreutils::Exception(std::string("mysql query failed: ") + mysql_error(mysql));
|
|
result = mysql_store_result(mysql);
|
|
if(result) {
|
|
row = mysql_fetch_row(result);
|
|
fieldLength = mysql_fetch_lengths(result);
|
|
qFields = mysql_num_fields(result);
|
|
// Built once per query, not once per column LOOKUP - see the
|
|
// matching comment on getColumnValue() and on fieldIndex's own
|
|
// declaration in __mysql.h. mysql_fetch_fields() returns the
|
|
// whole field array in one call (mysql_fetch_field_direct()
|
|
// looping ix=0..qFields-1, as getColumnValue() used to do on
|
|
// every single named access, does the exact same work over and
|
|
// over for a result set whose schema never changes after this
|
|
// point).
|
|
fieldIndex.clear();
|
|
MYSQL_FIELD *fields = mysql_fetch_fields(result);
|
|
for(unsigned int ix = 0; ix < qFields; ++ix)
|
|
fieldIndex[std::string(fields[ix].name)] = (int)ix;
|
|
} else {
|
|
// A failed/errored query (real content builds one from an unset
|
|
// $[...] parameter, e.g. "where tasks.id=" with no value - genuine
|
|
// MySQL syntax error) previously left row/qFields holding whatever
|
|
// a PRIOR successful query on this same session object had set,
|
|
// since only the success branch above ever touched them. A later
|
|
// getColumnValue() call then looped 0..qFields (stale, nonzero)
|
|
// calling mysql_fetch_field_direct() against this NULL `result` -
|
|
// segfaulting (confirmed via gdb: SIGSEGV in
|
|
// mysql_fetch_field_direct, called from getColumnValue) instead of
|
|
// reaching the "column does not exist in session result." the
|
|
// caller already throws/catches for a genuinely missing column.
|
|
// Resetting both here makes a failed query present as "no row, no
|
|
// columns" - clean and consistent regardless of what ran before it
|
|
// on this session.
|
|
row = NULL;
|
|
qFields = 0;
|
|
fieldIndex.clear();
|
|
}
|
|
}
|
|
|
|
void __mysql::nextRow() {
|
|
row = mysql_fetch_row(result);
|
|
fieldLength = mysql_fetch_lengths(result);
|
|
}
|
|
|
|
bool __mysql::hasRow() {
|
|
return row != NULL;
|
|
}
|
|
|
|
coreutils::ZString __mysql::getColumnValue(coreutils::ZString column) {
|
|
MYSQL_FIELD *field;
|
|
if(column == "?#") {
|
|
nbrOfColumns = (int)qFields;
|
|
return nbrOfColumns;
|
|
} else if(column.ifNext("#") || column.ifNext("?")) {
|
|
// "#N"/"?N" (column name at 1-based position N) are the same
|
|
// jet-1.4 syntax under two different prefixes - real, unmodified
|
|
// 1.4 content uses both (barant.tag's own <showtable> definition
|
|
// writes $[?$[index]], never $[#$[index]]) - confirmed via a real
|
|
// repro: admin.page's own Users/Products tables rendered every
|
|
// column header and cell completely blank, since "?N" fell all
|
|
// the way through to the ordinary column-VALUE lookup below
|
|
// (nothing named "?3" exists in the row) and read as empty rather
|
|
// than as "the name of column 3", per the jet-1.4-fallback #2
|
|
// empty-string-on-missing-column behavior elsewhere in this
|
|
// codebase (see Tag.cpp).
|
|
if(column.eod()) {
|
|
nbrOfRows = (int)mysql_num_rows(result);
|
|
return nbrOfRows;
|
|
} else {
|
|
int index = column.asInteger();
|
|
field = mysql_fetch_field_direct(result, index - 1);
|
|
return coreutils::ZString(field->name);
|
|
}
|
|
}
|
|
|
|
// A query that ran successfully but matched zero rows (a real,
|
|
// valid query - e.g. cart.page's own aggregate/JOIN query against
|
|
// an empty cart session - not a failed one, see query()'s own
|
|
// row=NULL/qFields=0 reset above for that separate case) leaves
|
|
// qFields correctly reflecting the real schema but `row` NULL.
|
|
// The loop below finds a genuine matching column name and then
|
|
// dereferences row[ix] - segfaulting (confirmed via gdb: SIGSEGV
|
|
// right here) instead of reaching a catchable error. Reusing the
|
|
// same "column does not exist" exception the loop's own fallthrough
|
|
// already throws - no current row means no column value is
|
|
// obtainable either way, and callers already expect and catch
|
|
// this exact exception.
|
|
if(!row)
|
|
throw coreutils::Exception("column does not exist in session result.");
|
|
|
|
// O(1) against the map query() already built for this result set,
|
|
// instead of re-deriving field metadata (mysql_fetch_field_direct,
|
|
// a real MySQL C API call) and string-comparing column names one
|
|
// by one on every single named access - see fieldIndex's own doc
|
|
// comment in __mysql.h for the full story.
|
|
auto it = fieldIndex.find(column.str());
|
|
if(it != fieldIndex.end())
|
|
return coreutils::ZString(row[it->second], fieldLength[it->second]);
|
|
throw coreutils::Exception("column does not exist in session result.");
|
|
}
|
|
|
|
}
|