Files
JetCore/__mysql.cpp
T
Brad ArantandClaude Sonnet 5 905fbafa5a Fix <sql> silent-failure bug; split /vst into overview + subpage; misc site fixes
- __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>
2026-09-13 15:14:25 +00:00

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.");
}
}