diff --git a/src/core/db/nuke_db.c b/src/core/db/nuke_db.c index 7edc06822..1c2c532ff 100644 --- a/src/core/db/nuke_db.c +++ b/src/core/db/nuke_db.c @@ -468,15 +468,30 @@ int cwist_nuke_init(const char *disk_path, int sync_interval_ms) { return CWIST_NUKE_OK; } +/** + * @brief Return the SQLite handle that callers should use for the NUKE database. + * @return The in-memory handle in normal mode, or the disk handle after a + * low-memory fallback to disk mode. May be NULL before init or after close. + */ sqlite3 *cwist_nuke_get_db(void) { if (g_nuke.is_disk_mode) return g_nuke.disk_db; return g_nuke.mem_db; } +/** + * @brief Placeholder signal handler (no-op). + * @param signum Signal number; unused. + */ void cwist_nuke_signal_handler(int signum) { CWIST_UNUSED(signum); } +/** + * @brief Serialize the current NUKE database contents into a buffer. + * @param out_size Receives the serialized byte count. + * @return Heap buffer produced by sqlite3_serialize (caller frees with sqlite3_free), + * or NULL when out_size is NULL or no database is open. Thread-safe via g_nuke_lock. + */ unsigned char *cwist_nuke_serialize(sqlite3_int64 *out_size) { if (!out_size) return NULL; *out_size = 0; @@ -493,6 +508,13 @@ unsigned char *cwist_nuke_serialize(sqlite3_int64 *out_size) { return buf; } +/** + * @brief Replace the in-memory NUKE database with serialized contents. + * @param data Serialized database buffer; ownership transfers to SQLite, which + * frees it on close (SQLITE_DESERIALIZE_FREEONCLOSE). + * @param data_len Length of @p data in bytes. + * @return 0 on success, or -1 on invalid arguments or when running in disk mode. + */ int cwist_nuke_deserialize(unsigned char *data, sqlite3_int64 data_len) { if (!data || data_len <= 0) return -1; diff --git a/src/core/db/pool.c b/src/core/db/pool.c index db0c9b3b6..b70bb7444 100644 --- a/src/core/db/pool.c +++ b/src/core/db/pool.c @@ -23,12 +23,16 @@ struct cwist_db_pool { static atomic_ulong pool_sequence = 1; +/** @brief Build the error reported when a pooled operation cannot acquire a connection. + * @return Error tagged @c CWIST_ERR_INT16 with code -1. The caller owns the result. */ static cwist_error_t pool_error(void) { cwist_error_t err = make_error(CWIST_ERR_INT16); err.error.err_i16 = -1; return err; } +/** @brief Pick the clock used for pool condition-variable deadlines. + * @return @c CLOCK_REALTIME on BSD/macOS (no monotonic condvar support), otherwise @c CLOCK_MONOTONIC. */ static clockid_t pool_cond_clock(void) { #if defined(__FreeBSD__) || defined(__NetBSD__) || defined(__OpenBSD__) || defined(__APPLE__) return CLOCK_REALTIME; @@ -37,6 +41,10 @@ static clockid_t pool_cond_clock(void) { #endif } +/** @brief Initialize a condition variable with the pool's deadline clock. + * @param cond Condition variable to initialize; must not be NULL. + * @retval true Initialization succeeded. + * @retval false Initialization failed; @a cond is left in an unusable state. */ static bool pool_cond_init(pthread_cond_t *cond) { #if defined(__FreeBSD__) || defined(__NetBSD__) || defined(__OpenBSD__) || defined(__APPLE__) return pthread_cond_init(cond, NULL) == 0; @@ -50,6 +58,9 @@ static bool pool_cond_init(pthread_cond_t *cond) { #endif } +/** @brief Compute an absolute deadline @a timeout_ms milliseconds from now. + * @param deadline Output absolute deadline on the pool condvar clock; must not be NULL. + * @param timeout_ms Timeout in milliseconds. */ static void pool_deadline_after_ms(struct timespec *deadline, int timeout_ms) { clock_gettime(pool_cond_clock(), deadline); deadline->tv_sec += timeout_ms / 1000; @@ -60,6 +71,11 @@ static void pool_deadline_after_ms(struct timespec *deadline, int timeout_ms) { } } +/** @brief Derive the SQLite open path for a pooled database. + * @param path Requested database path. + * @return Newly allocated open path (caller frees), or NULL on allocation failure. The + * ":memory:" pseudo-path is rewritten to a unique shared-cache URI so every pooled + * connection sees one in-memory database. */ static char *pool_open_path(const char *path) { if (strcmp(path, ":memory:") != 0) return cwist_strdup(path); unsigned long seq = atomic_fetch_add_explicit(&pool_sequence, 1, memory_order_relaxed); @@ -69,6 +85,11 @@ static char *pool_open_path(const char *path) { return written > 0 && (size_t)written < sizeof(buffer) ? cwist_strdup(buffer) : NULL; } +/** @brief Create a pool of @a max_conns SQLite connections to @a path. + * @param path Database path, or ":memory:" for a shared in-memory database. + * @param max_conns Maximum number of pooled connections; must be > 0. + * @return New pool on success, or NULL on invalid arguments or any allocation/open failure. + * On success all connections start idle. Thread-safe. */ cwist_db_pool_t *cwist_db_pool_create(const char *path, size_t max_conns) { if (!path || max_conns == 0) return NULL; cwist_db_pool_t *pool = cwist_alloc(sizeof(*pool)); @@ -118,6 +139,9 @@ cwist_db_pool_t *cwist_db_pool_create(const char *path, size_t max_conns) { return NULL; } +/** @brief Take an idle connection from the pool; caller must hold @c pool->mtx. + * @param pool Pool to lease from. + * @return Leased connection, or NULL if the pool is closing or has no idle connections. */ static cwist_db *pool_acquire_locked(cwist_db_pool_t *pool) { if (pool->closing || pool->idle_count == 0) return NULL; size_t slot = pool->idle_slots[--pool->idle_count]; @@ -126,6 +150,10 @@ static cwist_db *pool_acquire_locked(cwist_db_pool_t *pool) { return pool->conns[slot]; } +/** @brief Lease a pooled connection, blocking indefinitely until one is available. + * @param pool Pool to lease from; must not be NULL. + * @return Leased connection to pass to cwist_db_pool_release(), or NULL if the pool is NULL or closing. + * Thread-safe; the caller must not use the connection after releasing it. */ cwist_db *cwist_db_pool_acquire(cwist_db_pool_t *pool) { if (!pool) return NULL; pthread_mutex_lock(&pool->mtx); @@ -135,6 +163,11 @@ cwist_db *cwist_db_pool_acquire(cwist_db_pool_t *pool) { return db; } +/** @brief Lease a pooled connection, waiting at most @a timeout_ms. + * @param pool Pool to lease from; must not be NULL. + * @param timeout_ms Maximum wait in milliseconds; must be >= 0. + * @return Leased connection to pass to cwist_db_pool_release(), or NULL on invalid arguments, + * on timeout, or if the pool is closing. Thread-safe. */ cwist_db *cwist_db_pool_acquire_timeout(cwist_db_pool_t *pool, int timeout_ms) { if (!pool || timeout_ms < 0) return NULL; struct timespec deadline; @@ -148,6 +181,11 @@ cwist_db *cwist_db_pool_acquire_timeout(cwist_db_pool_t *pool, int timeout_ms) { return db; } +/** @brief Return a leased connection to the pool and wake one waiting acquirer. + * @param pool Pool that owns the connection. + * @param conn Connection previously returned by an acquire call; must belong to @a pool + * and still be leased. Invalid or already-released connections are ignored. + * Thread-safe. */ void cwist_db_pool_release(cwist_db_pool_t *pool, cwist_db *conn) { if (!pool || !conn) return; pthread_mutex_lock(&pool->mtx); @@ -162,6 +200,9 @@ void cwist_db_pool_release(cwist_db_pool_t *pool, cwist_db *conn) { pthread_mutex_unlock(&pool->mtx); } +/** @brief Count connections currently leased from the pool. + * @param pool Pool to inspect. + * @return Number of connections in use, or 0 if @a pool is NULL. Thread-safe. */ size_t cwist_db_pool_in_use(cwist_db_pool_t *pool) { if (!pool) return 0; pthread_mutex_lock(&pool->mtx); @@ -170,6 +211,14 @@ size_t cwist_db_pool_in_use(cwist_db_pool_t *pool) { return count; } +/** @brief Close a pool, waiting up to @a timeout_ms for leased connections. + * @param pool Pool to destroy; must not be NULL. + * @param timeout_ms Wait limit in milliseconds, or -1 to wait indefinitely for all + * connections to be released. + * @retval true All connections were released and the pool (and every connection) was closed and freed. + * @retval false The wait timed out with connections still leased; the pool is left alive and closing, + * so further acquires fail and the caller must retry the destroy. + * Thread-safe. */ bool cwist_db_pool_destroy_timeout(cwist_db_pool_t *pool, int timeout_ms) { if (!pool || timeout_ms < -1) return false; pthread_mutex_lock(&pool->mtx); @@ -201,10 +250,18 @@ bool cwist_db_pool_destroy_timeout(cwist_db_pool_t *pool, int timeout_ms) { return true; } +/** @brief Close a pool, blocking until all leased connections are released. + * @param pool Pool to destroy; no-op if NULL. Equivalent to + * cwist_db_pool_destroy_timeout(pool, -1). */ void cwist_db_pool_destroy(cwist_db_pool_t *pool) { (void)cwist_db_pool_destroy_timeout(pool, -1); } +/** @brief Execute a SQL statement on a leased pooled connection. + * @param pool Pool to run the statement on. + * @param sql SQL statement to execute. + * @return Execution result, or an INT16 error with code -1 if no connection could be acquired. + * The caller owns the result. */ cwist_error_t cwist_db_pool_exec(cwist_db_pool_t *pool, const char *sql) { cwist_db *db = cwist_db_pool_acquire(pool); if (!db) return pool_error(); @@ -213,6 +270,12 @@ cwist_error_t cwist_db_pool_exec(cwist_db_pool_t *pool, const char *sql) { return err; } +/** @brief Run a query on a leased pooled connection. + * @param pool Pool to run the query on. + * @param sql SQL query to execute. + * @param result Output JSON result written by the query; caller owns and must free it. + * @return Query result, or an INT16 error with code -1 if no connection could be acquired. + * The connection is released back to the pool before returning. */ cwist_error_t cwist_db_pool_query(cwist_db_pool_t *pool, const char *sql, cJSON **result) { cwist_db *db = cwist_db_pool_acquire(pool); if (!db) return pool_error(); diff --git a/src/core/html/component.c b/src/core/html/component.c index 5fd72b482..b97af047a 100644 --- a/src/core/html/component.c +++ b/src/core/html/component.c @@ -11,6 +11,14 @@ struct cwist_html_component { cwist_html_component_render_fn render_fn; }; +/** + * @brief Create a named component wrapping a render function. + * @param name Non-empty component name; copied into the new component. + * @param render_fn Render function invoked by cwist_html_component_instantiate(). + * @return New component on success, NULL if arguments are invalid or + * allocation fails. Caller owns the returned component and must + * release it with cwist_html_component_destroy(). + */ cwist_html_component_t *cwist_html_component_create(const char *name, cwist_html_component_render_fn render_fn) { if (!name || !*name || !render_fn) return NULL; @@ -28,16 +36,38 @@ cwist_html_component_t *cwist_html_component_create(const char *name, return comp; } +/** + * @brief Destroy a component and free its name. + * @param comp Component to destroy; NULL is accepted and does nothing. + * Must not be used after this call. + */ void cwist_html_component_destroy(cwist_html_component_t *comp) { if (!comp) return; cwist_free(comp->name); cwist_free(comp); } +/** + * @brief Get the component's name. + * @param comp Component to query; may be NULL. + * @return The component's name, or NULL if @p comp is NULL. + */ const char *cwist_html_component_name(const cwist_html_component_t *comp) { return comp ? comp->name : NULL; } +/** + * @brief Run the component's render function to produce an element tree. + * @param comp Component whose render function is invoked; may be NULL. + * @param props Opaque props forwarded to the render function. + * @param children Child elements forwarded to the render function. When + * @p comp is NULL they are destroyed here instead, since no render + * function takes ownership of them. + * @param child_count Number of entries in @p children. + * @return Rendered element tree, or NULL if arguments are invalid or the + * render function fails. Ownership follows the render function's + * contract. + */ cwist_html_element_t *cwist_html_component_instantiate(cwist_html_component_t *comp, const void *props, cwist_html_element_t **children, diff --git a/src/core/html/css_composer.c b/src/core/html/css_composer.c index d5f4823e3..421c80611 100644 --- a/src/core/html/css_composer.c +++ b/src/core/html/css_composer.c @@ -15,6 +15,12 @@ #define MIN3(a, b, c) ((a) < (b) ? ((a) < (c) ? (a) : (c)) : ((b) < (c) ? (b) : (c))) #define MAX3(a, b, c) ((a) > (b) ? ((a) > (c) ? (a) : (c)) : ((b) > (c) ? (b) : (c))) +/** + * @brief Convert an RGB color to the HSL color space. + * @param rgb RGB components in the range 0-255. + * @return HSL with hue in degrees (0-360), saturation and lightness in 0-1. + * Achromatic colors yield saturation and hue of 0. + */ cwist_color_hsl cwist_color_rgb_to_hsl(cwist_color_rgb rgb) { cwist_color_hsl hsl = {0.0f, 0.0f, 0.0f}; float r = rgb.r / 255.0f; @@ -45,6 +51,13 @@ cwist_color_hsl cwist_color_rgb_to_hsl(cwist_color_rgb rgb) { return hsl; } +/** + * @brief Helper for HSL-to-RGB conversion; maps a hue segment to a channel value. + * @param p Minimum channel value (from the HSL conversion formula). + * @param q Maximum channel value (from the HSL conversion formula). + * @param t Hue fraction; wrapped into the range [0, 1) before evaluation. + * @return The channel value in the range [p, q]. + */ static float hue2rgb(float p, float q, float t) { if (t < 0.0f) t += 1.0f; if (t > 1.0f) t -= 1.0f; @@ -54,6 +67,12 @@ static float hue2rgb(float p, float q, float t) { return p; } +/** + * @brief Convert an HSL color to the RGB color space. + * @param hsl Hue in degrees (0-360), saturation and lightness in 0-1. + * @return RGB components in the range 0-255, rounded to the nearest integer. + * A saturation of 0 yields a grayscale color. + */ cwist_color_rgb cwist_color_hsl_to_rgb(cwist_color_hsl hsl) { cwist_color_rgb rgb = {0, 0, 0}; float h = hsl.h / 360.0f; @@ -72,6 +91,11 @@ cwist_color_rgb cwist_color_hsl_to_rgb(cwist_color_hsl hsl) { return rgb; } +/** + * @brief Convert a single hexadecimal digit character to its numeric value. + * @param c Hex digit character ('0'-'9', 'a'-'f', or 'A'-'F'). + * @return Numeric value 0-15, or -1 if @p c is not a valid hex digit. + */ static inline int hex_char_to_val(char c) { if (c >= '0' && c <= '9') return c - '0'; if (c >= 'a' && c <= 'f') return c - 'a' + 10; @@ -79,6 +103,13 @@ static inline int hex_char_to_val(char c) { return -1; } +/** + * @brief Parse a hexadecimal color string into an RGB color. + * @param hex Hex string in "#rrggbb", "rrggbb", "#rgb", or "rgb" form. + * A leading '#' is optional. + * @return Parsed RGB color. Returns black ({0, 0, 0}) if @p hex is NULL or + * malformed (wrong length or non-hex characters). + */ cwist_color_rgb cwist_color_hex_to_rgb(const char *hex) { cwist_color_rgb rgb = {0, 0, 0}; if (!hex) return rgb; @@ -109,12 +140,23 @@ cwist_color_rgb cwist_color_hex_to_rgb(const char *hex) { return rgb; } +/** + * @brief Format an RGB color as a lowercase "#rrggbb" hex string. + * @param rgb RGB components in the range 0-255. + * @param out_hex Output buffer of at least 8 bytes; left untouched if NULL. + */ void cwist_color_rgb_to_hex(cwist_color_rgb rgb, char out_hex[8]) { if (out_hex) { snprintf(out_hex, 8, "#%02x%02x%02x", rgb.r, rgb.g, rgb.b); } } +/** + * @brief Initialize a CSS configuration with default values. + * @param cfg Configuration to fill. No-op if NULL. + * @details Defaults: Tailwind blue primary, Tailwind green secondary, + * 8px roundness, 4px spacing base, light mode. + */ void cwist_css_config_init(cwist_css_config *cfg) { if (!cfg) return; cfg->primary_color = cwist_color_hex_to_rgb("#3B82F6"); // Default Tailwind Blue @@ -136,6 +178,12 @@ static void get_shifted_hex(cwist_color_rgb base, float light_shift, char out[8] cwist_color_rgb_to_hex(shifted, out); } +/** + * @brief Generate the CSS custom-property block (:root variables) for a config. + * @param cfg Theme configuration; must not be NULL. + * @return Newly created sstring with the generated CSS, or NULL if @p cfg is + * NULL or allocation fails. Caller owns the returned string. + */ cwist_sstring *cwist_css_generate_variables(const cwist_css_config *cfg) { if (!cfg) return NULL; cwist_sstring *css = cwist_sstring_create(); @@ -183,6 +231,12 @@ cwist_sstring *cwist_css_generate_variables(const cwist_css_config *cfg) { return css; } +/** + * @brief Generate the utility/component CSS classes that reference the theme variables. + * @param cfg Theme configuration (currently unused; classes reference CSS variables). + * @return Newly created sstring with the generated CSS, or NULL on allocation + * failure. Caller owns the returned string. + */ cwist_sstring *cwist_css_generate_utility_classes(const cwist_css_config *cfg) { (void)cfg; // Currently relying on generated variables cwist_sstring *css = cwist_sstring_create(); @@ -223,6 +277,12 @@ cwist_sstring *cwist_css_generate_utility_classes(const cwist_css_config *cfg) { return css; } +/** + * @brief Generate a complete stylesheet (variables plus utility classes). + * @param cfg Theme configuration; must not be NULL. + * @return Newly created sstring with the concatenated CSS, or NULL if @p cfg + * is NULL or allocation fails. Caller owns the returned string. + */ cwist_sstring *cwist_css_generate_stylesheet(const cwist_css_config *cfg) { if (!cfg) return NULL; cwist_sstring *final_css = cwist_sstring_create(); @@ -257,6 +317,12 @@ static bool append_ok(cwist_sstring *s, const char *text) { return ok; } +/** + * @brief Validate that a string is a safe CSS identifier. + * @param s Candidate identifier; may be NULL. + * @retval true @p s is a non-empty CSS identifier ([-]?[a-zA-Z_][a-zA-Z0-9_-]*). + * @retval false @p s is NULL, empty, or contains other characters. + */ static bool is_css_ident(const char *s) { if (!s || !*s) return false; const char *p = s; @@ -268,6 +334,12 @@ static bool is_css_ident(const char *s) { return true; } +/** + * @brief Look up a scope entry by its base class name. + * @param scope Scope to search; must not be NULL. + * @param base_class Base class key to find. + * @return Pointer to the entry, or NULL if not present. + */ static struct cwist_css_scope_entry *scope_find(const cwist_css_scope *scope, const char *base_class) { for (size_t i = 0; i < scope->count; i++) { @@ -311,6 +383,14 @@ static struct cwist_css_scope_entry *scope_get_or_add(cwist_css_scope *scope, return entry; } +/** + * @brief Initialize a CSS scoping context, deriving a deterministic class suffix. + * @param scope Scope to initialize. No-op if NULL. + * @param component_name Name used to derive the suffix; empty string if NULL. + * @details The suffix is an unseeded FNV-1a 32-bit hash of the component name, + * so markup and stylesheets generated in different worker processes + * agree on the same suffix. Call cwist_css_scope_destroy() to release. + */ void cwist_css_scope_init(cwist_css_scope *scope, const char *component_name) { if (!scope) return; const char *name = component_name ? component_name : ""; @@ -329,6 +409,13 @@ void cwist_css_scope_init(cwist_css_scope *scope, const char *component_name) { scope->capacity = 0; } +/** + * @brief Map a base class to its scoped class name, marking it as used. + * @param scope Scope to query. + * @param base_class Base class name; must be a valid CSS identifier. + * @return Pointer to the scoped class name (e.g. "btn-a1b2c3d4") owned by the + * scope entry, or NULL on invalid input or allocation failure. + */ const char *cwist_css_scope_class(cwist_css_scope *scope, const char *base_class) { if (!scope || !scope->suffix[0] || !is_css_ident(base_class)) return NULL; struct cwist_css_scope_entry *entry = scope_get_or_add(scope, base_class); @@ -337,6 +424,15 @@ const char *cwist_css_scope_class(cwist_css_scope *scope, const char *base_class return entry->scoped_class->data; } +/** + * @brief Attach CSS declarations to a scoped class. + * @param scope Scope to modify. + * @param base_class Base class name; must be a valid CSS identifier. + * @param declarations Declaration block text (e.g. "color: red;"). Must not + * contain '{', '}', or '<'. + * @retval 0 Declarations stored; any previous declarations are freed. + * @retval -1 Invalid argument, unsafe declaration content, or allocation failure. + */ int cwist_css_scope_add_rule(cwist_css_scope *scope, const char *base_class, const char *declarations) { if (!scope || !scope->suffix[0] || !is_css_ident(base_class) || !declarations) return -1; @@ -354,6 +450,13 @@ int cwist_css_scope_add_rule(cwist_css_scope *scope, const char *base_class, return 0; } +/** + * @brief Emit the stylesheet for all used scope entries that have declarations. + * @param scope Scope to render; must not be NULL. + * @return Newly created sstring with one ". { ... }" rule per + * used entry, or NULL if @p scope is NULL or allocation fails. + * Caller owns the returned string. + */ cwist_sstring *cwist_css_scope_generate_stylesheet(const cwist_css_scope *scope) { if (!scope) return NULL; cwist_sstring *css = cwist_sstring_create(); @@ -376,6 +479,12 @@ cwist_sstring *cwist_css_scope_generate_stylesheet(const cwist_css_scope *scope) return css; } +/** + * @brief Destroy a CSS scoping context and free all owned resources. + * @param scope Scope to tear down. No-op if NULL. + * @details Frees all entry base names, scoped class strings, and declaration + * copies, then zeroes the scope struct. + */ void cwist_css_scope_destroy(cwist_css_scope *scope) { if (!scope) return; for (size_t i = 0; i < scope->count; i++) { @@ -387,20 +496,45 @@ void cwist_css_scope_destroy(cwist_css_scope *scope) { memset(scope, 0, sizeof(*scope)); } +/** + * @brief Test whether a character is CSS whitespace. + * @param c Character to test. + * @retval true @p c is ' ', '\\t', '\\n', '\\r', or '\\f'. + */ static bool css_is_space(char c) { return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\f'; } +/** + * @brief Test whether a character can be part of a CSS token glued to neighbors. + * @param c Character to test. + * @retval true @p c is alphanumeric, '-', '_', '%', '.', '#', '\\', or non-ASCII (>= 0x80). + */ static bool css_is_word(char c) { return isalnum((unsigned char)c) || c == '-' || c == '_' || c == '%' || c == '.' || c == '#' || c == '\\' || (unsigned char)c >= 0x80; } +/** + * @brief Test whether the string at @p p starts with an unquoted url( prefix + * (any case). + * @param p Pointer into CSS source text; must have at least 4 readable bytes. + * @retval true The next four characters are "url(" case-insensitively. + */ static bool css_starts_url(const char *p) { return (p[0] == 'u' || p[0] == 'U') && (p[1] == 'r' || p[1] == 'R') && (p[2] == 'l' || p[2] == 'L') && p[3] == '('; } +/** + * @brief Minify CSS source by stripping comments and collapsing whitespace. + * @param css CSS source text; must not be NULL. + * @return Newly created sstring with the minified CSS, or NULL on invalid + * input or allocation failure. Caller owns the returned string. + * @details Preserves /*!...*!/ comments verbatim, quoted strings, escapes, and + * unquoted url() arguments. Whitespace around structural characters + * and comments is dropped unless removing it would glue two tokens. + */ cwist_sstring *cwist_css_minify(const char *css) { if (!css) return NULL; size_t n = strlen(css); @@ -500,6 +634,14 @@ if (!ok) { return result; } +/** + * @brief Concatenate CSS parts into one bundle, optionally minified. + * @param parts Array of CSS text fragments; may be NULL only if @p count is 0. + * @param count Number of entries in @p parts. + * @param minify If true, the concatenated result is passed through cwist_css_minify(). + * @return Newly created sstring with the bundled CSS, or NULL on invalid input + * or allocation failure. Caller owns the returned string. + */ cwist_sstring *cwist_css_bundle(const char *const *parts, size_t count, bool minify) { if (!parts && count > 0) return NULL; cwist_sstring *bundle = cwist_sstring_create(); diff --git a/src/core/log/log.c b/src/core/log/log.c index 20e885fdd..56b9ca470 100644 --- a/src/core/log/log.c +++ b/src/core/log/log.c @@ -11,6 +11,17 @@ cwist_macro_log_level_t g_cwist_log_level = CWIST_LOG_LEVEL_NONE; +/** + * @brief Write a single log record to stderr with a timestamp and level tag. + * + * Emits one line of the form `[LEVEL] [YYYY-MM-DD HH:MM:SS] ` + * to stderr. Unknown level values print as `???`. Output is not buffered beyond + * the C runtime's default stderr buffering; no synchronization is performed, so + * interleaving may occur when called concurrently from multiple threads. + * + * @param level Severity tag for the record (INFO, WARN, ERROR, DEBUG, or other). + * @param fmt printf-style format string, followed by its arguments. + */ void cwist_log_write(cwist_macro_log_level_t level, const char *fmt, ...) { const char *level_str = "???"; switch (level) { diff --git a/src/core/mem/gc.c b/src/core/mem/gc.c index a627c722e..f257ab069 100644 --- a/src/core/mem/gc.c +++ b/src/core/mem/gc.c @@ -206,10 +206,15 @@ __attribute__((constructor)) static void cwist_full_gc_guard_init(void) { static cwist_gc_t g_full_gc; static pthread_once_t g_full_gc_once = PTHREAD_ONCE_INIT; +/** @brief pthread_once body: initialize the full-GC instance in manual-rotation mode. */ static void cwist_full_gc_lazy_init(void) { cwist_gc(&g_full_gc, true); } +/** + * @brief Lazily initialized process-wide GC instance used by full-GC mode. + * @return Shared full-GC context, initialized on first call. + */ static cwist_gc_t *cwist_full_gc_instance(void) { pthread_once(&g_full_gc_once, cwist_full_gc_lazy_init); return &g_full_gc; @@ -229,6 +234,16 @@ static cwist_gc_t *cwist_full_gc_instance(void) { */ static pthread_mutex_t g_full_gc_claim_mu = PTHREAD_MUTEX_INITIALIZER; +/** + * @brief Claim full-GC mode permanently, exactly once, then harden the toggle. + * + * The first caller stores @p enable into the guard page, marks the page + * locked, and mprotect()s it read-only; every later call is a silent no-op. + * Fails safe (no-op) when the guard page was never mapped. Also toggles + * auto-rotation on the process-wide full-GC instance. + * + * @param enable true to enable full-GC mode, false to claim-and-disable. + */ void cwist_full_gc(bool enable) { if (!g_full_gc_guard) return; /* guard page unavailable; fail safe, not silently unhardened */ @@ -255,16 +270,28 @@ void cwist_full_gc(bool enable) { cwist_gc_auto_rotate(cwist_full_gc_instance(), enable); } +/** + * @brief Query whether full-GC mode was enabled by the (single) cwist_full_gc() claim. + * @return Current enabled state; false when the guard page was never mapped. + */ bool cwist_full_gc_enabled(void) { if (!g_full_gc_guard) return false; return atomic_load_explicit(&g_full_gc_guard->enabled, memory_order_relaxed); } +/** + * @brief Query whether the full-GC toggle has already been claimed and locked. + * @return true once cwist_full_gc() has run its claiming path exactly once. + */ bool cwist_full_gc_locked(void) { if (!g_full_gc_guard) return false; return atomic_load_explicit(&g_full_gc_guard->locked, memory_order_acquire); } +/** + * @brief Expose the hardened full-GC guard page for introspection/tests. + * @return Pointer to the guard page, or NULL if mmap() failed at startup. + */ void *cwist_full_gc_guard_page(void) { return g_full_gc_guard; } @@ -416,6 +443,14 @@ static cwist_gc_pending_t *cwist_gc_pending_get(void) { return pending; } +/** + * @brief Track a live cwist_alloc() block so full-GC can retire it later. + * + * Best-effort: on allocation failure the block is left untracked rather + * than failing the caller's allocation. Duplicate pointers are stored once. + * + * @param ptr Block to track; a no-op when NULL. + */ void cwist_gc_scope_track(void *ptr) { if (!ptr) return; cwist_gc_pending_t *pending = cwist_gc_pending_get(); @@ -433,6 +468,16 @@ void cwist_gc_scope_track(void *ptr) { pending->count++; } +/** + * @brief Stop tracking a block without retiring it. + * + * Used when the caller hands ownership elsewhere (e.g. frees through a + * different path); the block is removed from this thread's pending set + * and will not be epoch-retired by a later flush. + * + * @param ptr Block to untrack. + * @return true when @p ptr was tracked and removed; false otherwise. + */ bool cwist_gc_scope_untrack(void *ptr) { if (!ptr) return false; cwist_gc_pending_t *pending = cwist_gc_pending_get(); @@ -460,14 +505,29 @@ bool cwist_gc_scope_untrack(void *ptr) { return true; } +/** + * @brief Alias of cwist_gc_scope_untrack(): drop tracking without retiring. + * @param ptr Block to untrack. + * @return true when @p ptr was tracked and removed; false otherwise. + */ bool cwist_gc_scope_disown(void *ptr) { return cwist_gc_scope_untrack(ptr); } +/** + * @brief Retire every pending block this thread is still tracking. + * + * Each tracked block is handed to cwist_ebr_free(), so reclamation is + * deferred until no thread can still be reading inside an epoch. + */ void cwist_gc_scope_flush(void) { cwist_gc_pending_flush(cwist_gc_pending_get()); } +/** + * @brief Count this thread's not-yet-retired tracked blocks. + * @return Number of pending entries; 0 when nothing is tracked. + */ size_t cwist_gc_scope_pending_count(void) { cwist_gc_pending_t *pending = cwist_gc_pending_get(); return pending ? pending->count : 0; @@ -594,6 +654,15 @@ static cwist_conn_pending_t *cwist_conn_pending_get(void) { return pending; } +/** + * @brief Track a non-memory resource for the full-GC exit sweep. + * + * No-op unless full-GC mode is enabled. Best-effort: if the per-thread + * list cannot grow, the handle is dropped rather than failing the caller. + * + * @param handle Resource handle to close later (e.g. socket, TLS session). + * @param close_fn Callback invoked with @p handle during a sweep or flush. + */ void cwist_conn_registry_track(void *handle, cwist_conn_close_fn close_fn) { if (!handle || !close_fn) return; if (!cwist_full_gc_enabled()) return; @@ -617,6 +686,11 @@ void cwist_conn_registry_track(void *handle, cwist_conn_close_fn close_fn) { pthread_mutex_unlock(&pending->lock); } +/** + * @brief Remove a handle from this thread's tracked-connection list. + * @param handle Handle previously passed to cwist_conn_registry_track(). + * @return true when found and removed; false otherwise. + */ bool cwist_conn_registry_untrack(void *handle) { if (!handle) return false; cwist_conn_pending_t *pending = cwist_conn_pending_get(); @@ -635,6 +709,12 @@ bool cwist_conn_registry_untrack(void *handle) { return found; } +/** + * @brief Close every handle this thread still tracks, right now. + * + * Unlike the sweeps, the list stays usable afterwards: entries are closed + * and cleared but the list is not marked swept, so later tracks still work. + */ void cwist_conn_registry_flush(void) { cwist_conn_pending_t *pending = cwist_conn_pending_get(); if (!pending) return; @@ -646,10 +726,20 @@ void cwist_conn_registry_flush(void) { pthread_mutex_unlock(&pending->lock); } +/** + * @brief Close every still-registered thread's tracked handles. + * + * Intended for process-exit paths; each list is closed at most once even + * if a thread's TLS destructor sweep races with this call. + */ void cwist_conn_registry_sweep_all(void) { cwist_conn_registry_sweep_all_impl(); } +/** + * @brief Count this thread's tracked, not-yet-closed handles. + * @return Number of pending entries; 0 when nothing is tracked. + */ size_t cwist_conn_registry_pending_count(void) { cwist_conn_pending_t *pending = cwist_conn_pending_get(); if (!pending) return 0; diff --git a/src/core/orm/orm.c b/src/core/orm/orm.c index 502b5856f..46e0bca59 100644 --- a/src/core/orm/orm.c +++ b/src/core/orm/orm.c @@ -132,6 +132,16 @@ static int orm_parse_status(const char *payload) { /* Lifecycle */ /* ------------------------------------------------------------------------- */ +/** + * @brief Wrap a connected socket descriptor in an ORM session. + * + * Takes ownership of @p socket_fd: the descriptor is closed by + * cwist_orm_close_socket(). The session inherits the current global + * immediate-commit default and defaults to the SQLite dialect. + * + * @param socket_fd Connected socket descriptor (must be >= 0). + * @return New ORM handle, or NULL on invalid descriptor or allocation failure. + */ cwist_orm_t *cwist_orm_open_socket(int socket_fd) { if (socket_fd < 0) return NULL; cwist_orm_t *orm = (cwist_orm_t *)calloc(1, sizeof(*orm)); @@ -142,6 +152,14 @@ cwist_orm_t *cwist_orm_open_socket(int socket_fd) { return orm; } +/** + * @brief Close an ORM session and release its socket. + * + * Issues a best-effort ROLLBACK to discard any dangling transaction, then + * closes the socket descriptor and frees the handle. Safe to call with NULL. + * + * @param orm Session to close (may be NULL). + */ void cwist_orm_close_socket(cwist_orm_t *orm) { if (!orm) return; if (orm->sock_fd >= 0) { @@ -158,18 +176,48 @@ void cwist_orm_close_socket(cwist_orm_t *orm) { /* Transaction control */ /* ------------------------------------------------------------------------- */ +/** + * @brief Set the global immediate-commit default for new ORM sessions. + * + * Sessions opened afterwards inherit this value; existing sessions are not + * affected. When enabled, INSERT/UPDATE/DELETE statements executed through + * cwist_orm_exec() are followed by an automatic COMMIT. + * + * @param enable true to enable immediate commit, false to disable. + */ void cwist_orm_immediate_commit(bool enable) { g_immediate_commit = enable; } +/** + * @brief Select the SQL dialect used for query generation. + * + * Affects identifier quoting and dialect-specific statements generated by + * the high-level helpers. Does nothing if @p orm is NULL. + * + * @param orm Session to configure. + * @param dialect Dialect to use (e.g. CWIST_ORM_SQLITE). + */ void cwist_orm_use_dialect(cwist_orm_t *orm, cwist_orm_dialect_t dialect) { if (orm) orm->dialect = dialect; } +/** + * @brief Commit the current transaction. + * + * @param orm Active session. + * @return CWIST_SUCCESS on success, otherwise an error code. + */ cwist_error_t cwist_orm_commit(cwist_orm_t *orm) { return cwist_orm_exec(orm, "COMMIT;"); } +/** + * @brief Roll back the current transaction. + * + * @param orm Active session. + * @return CWIST_SUCCESS on success, otherwise an error code. + */ cwist_error_t cwist_orm_rollback(cwist_orm_t *orm) { return cwist_orm_exec(orm, "ROLLBACK;"); } @@ -178,6 +226,18 @@ cwist_error_t cwist_orm_rollback(cwist_orm_t *orm) { /* Low-level SQL */ /* ------------------------------------------------------------------------- */ +/** + * @brief Execute a single SQL statement, discarding any result rows. + * + * If immediate-commit mode is enabled, an INSERT/UPDATE/DELETE statement is + * followed by an automatic COMMIT. + * + * @param orm Active session. + * @param sql Null-terminated SQL statement. + * @return CWIST_SUCCESS on success; CWIST_ERROR_INVALID_PARAM for NULL + * arguments, CWIST_ERROR_IO on transport failure, or + * CWIST_ERROR_PROTOCOL when the backend reports an error. + */ cwist_error_t cwist_orm_exec(cwist_orm_t *orm, const char *sql) { cwist_error_t err = make_error(CWIST_ERR_INT16); if (!orm || !sql) { @@ -215,6 +275,19 @@ cwist_error_t cwist_orm_exec(cwist_orm_t *orm, const char *sql) { return err; } +/** + * @brief Execute a SELECT-style query and return the result rows. + * + * On success @p *result receives a cJSON array of row objects (empty array + * if no rows) that the caller must release with cJSON_Delete(). + * + * @param orm Active session. + * @param sql Null-terminated SQL query. + * @param result [out] Receives the rows array. + * @return CWIST_SUCCESS on success; CWIST_ERROR_INVALID_PARAM for NULL + * arguments, CWIST_ERROR_IO on transport failure, or + * CWIST_ERROR_PROTOCOL on backend error or malformed response. + */ cwist_error_t cwist_orm_query(cwist_orm_t *orm, const char *sql, cJSON **result) { cwist_error_t err = make_error(CWIST_ERR_INT16); *result = NULL; @@ -285,14 +358,6 @@ static char *cwist_orm_escape_sqlite(const char *src) { return dst; } -/** - * @brief Convert a single cJSON node into an SQL literal fragment. - * - * The returned string is heap-allocated and must be freed by the caller. - * - * @param node cJSON value node. - * @return SQL literal string, or NULL on allocation failure. - */ /** * @brief Quote an SQL identifier according to the active dialect. * @@ -338,6 +403,18 @@ static char *cwist_orm_quote_identifier(const cwist_orm_t *orm, const char *id) return out; } +/** + * @brief Convert a single cJSON node into an SQL literal fragment. + * + * Maps cJSON types to SQL literals (NULL, booleans as TRUE/FALSE or 1/0 by + * dialect, numbers via %g, strings single-quoted with quote doubling, and + * arrays/objects as single-quoted JSON text). The returned string is + * heap-allocated and must be freed by the caller. + * + * @param orm Session (determines boolean rendering; may be NULL). + * @param node cJSON value node (may be NULL, treated as SQL NULL). + * @return SQL literal string, or NULL on allocation failure. + */ static char *cwist_orm_json_to_sql_literal(const cwist_orm_t *orm, const cJSON *node) { if (!node) return strdup("NULL"); @@ -493,6 +570,19 @@ static char *orm_build_insert_sql(const cwist_orm_t *orm, const char *table, con return sql; } +/** + * @brief Insert a JSON object into a table. + * + * Column names and values are taken from the object's keys/values and + * rendered according to the session dialect. + * + * @param orm Active session. + * @param table Table name. + * @param data JSON object with column names as keys. + * @return CWIST_SUCCESS on success; CWIST_ERROR_INVALID_PARAM for NULL + * arguments or a non-object @p data, CWIST_ERROR_NOMEM on + * allocation failure, otherwise the error from cwist_orm_exec(). + */ cwist_error_t cwist_orm_insert(cwist_orm_t *orm, const char *table, const cJSON *data) { cwist_error_t err = make_error(CWIST_ERR_INT16); if (!orm || !table || !data || !cJSON_IsObject(data)) { @@ -521,6 +611,21 @@ cwist_error_t cwist_orm_insert(cwist_orm_t *orm, const char *table, const cJSON return err; } +/** + * @brief Update rows of a table with values from a JSON object. + * + * Builds UPDATE table SET key=value,... from the object's entries; an empty + * object is rejected. If @p where_clause is non-empty it is appended as a + * WHERE clause (a NULL or empty clause updates all rows). + * + * @param orm Active session. + * @param table Table name. + * @param data JSON object with column names as keys (must be non-empty). + * @param where_clause Optional raw SQL WHERE condition (not escaped). + * @return CWIST_SUCCESS on success; CWIST_ERROR_INVALID_PARAM for NULL + * arguments or an empty object, CWIST_ERROR_NOMEM on allocation + * failure, otherwise the error from cwist_orm_exec(). + */ cwist_error_t cwist_orm_update(cwist_orm_t *orm, const char *table, const cJSON *data, const char *where_clause) { cwist_error_t err = make_error(CWIST_ERR_INT16); @@ -606,6 +711,19 @@ cwist_error_t cwist_orm_update(cwist_orm_t *orm, const char *table, const cJSON return err; } +/** + * @brief Delete rows from a table. + * + * If @p where_clause is non-empty it is appended as a WHERE clause; a NULL + * or empty clause deletes all rows. + * + * @param orm Active session. + * @param table Table name. + * @param where_clause Optional raw SQL WHERE condition (not escaped). + * @return CWIST_SUCCESS on success; CWIST_ERROR_INVALID_PARAM for NULL + * arguments, CWIST_ERROR_NOMEM on allocation failure, otherwise + * the error from cwist_orm_exec(). + */ cwist_error_t cwist_orm_delete(cwist_orm_t *orm, const char *table, const char *where_clause) { cwist_error_t err = make_error(CWIST_ERR_INT16); if (!orm || !table) { @@ -639,6 +757,19 @@ cwist_error_t cwist_orm_delete(cwist_orm_t *orm, const char *table, const char * return err; } +/** + * @brief Select rows from a table. + * + * @param orm Active session. + * @param table Table name. + * @param columns Raw comma-separated column list (not escaped). + * @param where_clause Optional raw SQL WHERE condition (not escaped). + * @param result [out] Receives a cJSON array of row objects; caller + * must release with cJSON_Delete(). + * @return CWIST_SUCCESS on success; CWIST_ERROR_INVALID_PARAM for NULL + * arguments, CWIST_ERROR_NOMEM on allocation failure, otherwise + * the error from cwist_orm_query(). + */ cwist_error_t cwist_orm_select(cwist_orm_t *orm, const char *table, const char *columns, const char *where_clause, cJSON **result) { cwist_error_t err = make_error(CWIST_ERR_INT16); @@ -679,6 +810,25 @@ cwist_error_t cwist_orm_select(cwist_orm_t *orm, const char *table, const char * /* _Generic helpers: RETURNING INSERT */ /* ------------------------------------------------------------------ */ +/** + * @brief Insert a row and return a JSON value from the new row. + * + * Appends a dialect-specific RETURNING clause (or an equivalent + * last-insert-id follow-up query) to the INSERT built from @p data, then + * extracts the @p returning_col value from the first result row. For + * MySQL/SQLite the returned value is the last insert id, not an arbitrary + * column. + * + * @param orm Active session. + * @param table Table name. + * @param data cJSON object with the column values to insert. + * @param returning_col Column (or alias) of the value to return. + * @param out [out] Receives a duplicated cJSON value; caller must + * release with cJSON_Delete(). + * @return CWIST_SUCCESS on success; CWIST_ERROR_INVALID_PARAM for NULL + * arguments, CWIST_ERROR_NOMEM on allocation failure, + * CWIST_ERROR_PROTOCOL when the value is missing from the result. + */ cwist_error_t cwist_orm_insert_returning_json(cwist_orm_t *orm, const char *table, const cJSON *data, const char *returning_col, cJSON **out) { @@ -754,6 +904,18 @@ cwist_error_t cwist_orm_insert_returning_json(cwist_orm_t *orm, const char *tabl return err; } +/** + * @brief Insert a row and return a column value as an int. + * + * @param orm Active session. + * @param table Table name. + * @param data cJSON object with the column values to insert. + * @param returning_col Column (or alias) of the value to return. + * @param out [out] Receives the value as an int. + * @return CWIST_SUCCESS on success; otherwise the error from + * cwist_orm_insert_returning_json() or CWIST_ERROR_PROTOCOL when + * the returned value is not a number. + */ cwist_error_t cwist_orm_insert_returning_int(cwist_orm_t *orm, const char *table, const cJSON *data, const char *returning_col, int *out) { cJSON *val = NULL; @@ -768,6 +930,18 @@ cwist_error_t cwist_orm_insert_returning_int(cwist_orm_t *orm, const char *table return err; } +/** + * @brief Insert a row and return a column value as a long. + * + * @param orm Active session. + * @param table Table name. + * @param data cJSON object with the column values to insert. + * @param returning_col Column (or alias) of the value to return. + * @param out [out] Receives the value as a long. + * @return CWIST_SUCCESS on success; otherwise the error from + * cwist_orm_insert_returning_json() or CWIST_ERROR_PROTOCOL when + * the returned value is not a number. + */ cwist_error_t cwist_orm_insert_returning_long(cwist_orm_t *orm, const char *table, const cJSON *data, const char *returning_col, long *out) { @@ -783,6 +957,18 @@ cwist_error_t cwist_orm_insert_returning_long(cwist_orm_t *orm, const char *tabl return err; } +/** + * @brief Insert a row and return a column value as a long long. + * + * @param orm Active session. + * @param table Table name. + * @param data cJSON object with the column values to insert. + * @param returning_col Column (or alias) of the value to return. + * @param out [out] Receives the value as a long long. + * @return CWIST_SUCCESS on success; otherwise the error from + * cwist_orm_insert_returning_json() or CWIST_ERROR_PROTOCOL when + * the returned value is not a number. + */ cwist_error_t cwist_orm_insert_returning_llong(cwist_orm_t *orm, const char *table, const cJSON *data, const char *returning_col, long long *out) { @@ -802,6 +988,22 @@ cwist_error_t cwist_orm_insert_returning_llong(cwist_orm_t *orm, const char *tab /* _Generic helpers: SELECT single scalar */ /* ------------------------------------------------------------------ */ +/** + * @brief Select a single column value from the first matching row as JSON. + * + * Runs a `SELECT column FROM table [WHERE ...] LIMIT 1` query and + * duplicates the column value from the first result row. + * + * @param orm Active session. + * @param table Table name. + * @param column Column name. + * @param where_clause Optional raw SQL WHERE condition (not escaped). + * @param out [out] Receives a duplicated cJSON value; caller must + * release with cJSON_Delete(). + * @return CWIST_SUCCESS on success; CWIST_ERROR_INVALID_PARAM for NULL + * arguments, CWIST_ERROR_NOMEM on allocation failure, + * CWIST_ERROR_PROTOCOL when no row or column value is returned. + */ cwist_error_t cwist_orm_select_one_json(cwist_orm_t *orm, const char *table, const char *column, const char *where_clause, cJSON **out) { cwist_error_t err = make_error(CWIST_ERR_INT16); @@ -869,6 +1071,18 @@ cwist_error_t cwist_orm_select_one_json(cwist_orm_t *orm, const char *table, con return err; } +/** + * @brief Select a single column value from the first matching row as an int. + * + * @param orm Active session. + * @param table Table name. + * @param column Column name. + * @param where_clause Optional raw SQL WHERE condition (not escaped). + * @param out [out] Receives the value as an int. + * @return CWIST_SUCCESS on success; otherwise the error from + * cwist_orm_select_one_json() or CWIST_ERROR_PROTOCOL when the + * returned value is not a number. + */ cwist_error_t cwist_orm_select_one_int(cwist_orm_t *orm, const char *table, const char *column, const char *where_clause, int *out) { cJSON *val = NULL; @@ -883,6 +1097,18 @@ cwist_error_t cwist_orm_select_one_int(cwist_orm_t *orm, const char *table, cons return err; } +/** + * @brief Select a single column value from the first matching row as a long. + * + * @param orm Active session. + * @param table Table name. + * @param column Column name. + * @param where_clause Optional raw SQL WHERE condition (not escaped). + * @param out [out] Receives the value as a long. + * @return CWIST_SUCCESS on success; otherwise the error from + * cwist_orm_select_one_json() or CWIST_ERROR_PROTOCOL when the + * returned value is not a number. + */ cwist_error_t cwist_orm_select_one_long(cwist_orm_t *orm, const char *table, const char *column, const char *where_clause, long *out) { cJSON *val = NULL; @@ -897,6 +1123,18 @@ cwist_error_t cwist_orm_select_one_long(cwist_orm_t *orm, const char *table, con return err; } +/** + * @brief Select a single column value from the first matching row as a long long. + * + * @param orm Active session. + * @param table Table name. + * @param column Column name. + * @param where_clause Optional raw SQL WHERE condition (not escaped). + * @param out [out] Receives the value as a long long. + * @return CWIST_SUCCESS on success; otherwise the error from + * cwist_orm_select_one_json() or CWIST_ERROR_PROTOCOL when the + * returned value is not a number. + */ cwist_error_t cwist_orm_select_one_llong(cwist_orm_t *orm, const char *table, const char *column, const char *where_clause, long long *out) { cJSON *val = NULL; @@ -911,6 +1149,18 @@ cwist_error_t cwist_orm_select_one_llong(cwist_orm_t *orm, const char *table, co return err; } +/** + * @brief Select a single column value from the first matching row as a double. + * + * @param orm Active session. + * @param table Table name. + * @param column Column name. + * @param where_clause Optional raw SQL WHERE condition (not escaped). + * @param out [out] Receives the value as a double. + * @return CWIST_SUCCESS on success; otherwise the error from + * cwist_orm_select_one_json() or CWIST_ERROR_PROTOCOL when the + * returned value is not a number. + */ cwist_error_t cwist_orm_select_one_double(cwist_orm_t *orm, const char *table, const char *column, const char *where_clause, double *out) { cJSON *val = NULL; @@ -925,6 +1175,20 @@ cwist_error_t cwist_orm_select_one_double(cwist_orm_t *orm, const char *table, c return err; } +/** + * @brief Select a single column value from the first matching row as a string. + * + * @param orm Active session. + * @param table Table name. + * @param column Column name. + * @param where_clause Optional raw SQL WHERE condition (not escaped). + * @param out [out] Receives a heap-allocated copy of the string; + * caller must release with free(). + * @return CWIST_SUCCESS on success; otherwise the error from + * cwist_orm_select_one_json(), CWIST_ERROR_PROTOCOL when the + * returned value is not a string, or CWIST_ERROR_NOMEM when the + * string copy cannot be allocated. + */ cwist_error_t cwist_orm_select_one_string(cwist_orm_t *orm, const char *table, const char *column, const char *where_clause, char **out) { cJSON *val = NULL; diff --git a/src/core/orm/rdbms_auto_mount.c b/src/core/orm/rdbms_auto_mount.c index a45b7db4d..b8f5becaa 100644 --- a/src/core/orm/rdbms_auto_mount.c +++ b/src/core/orm/rdbms_auto_mount.c @@ -67,6 +67,19 @@ static cwist_rdbms_provider_t classify_mysql(const char *buf, size_t len) { return CWIST_RDBMS_MYSQL; } +/** + * @brief Probe a TCP port on 127.0.0.1 to detect the RDBMS provider. + * + * Connects to the given port with a 2-second send/recv timeout, inspects + * any unsolicited handshake bytes as a MySQL/MariaDB handshake, and if that + * does not match, sends a PostgreSQL StartupMessage and waits for an + * AuthenticationRequest response. + * + * @param port TCP port on localhost to probe. + * @return Detected provider (@c CWIST_RDBMS_MYSQL, @c CWIST_RDBMS_MARIADB, + * or @c CWIST_RDBMS_POSTGRES) or @c CWIST_RDBMS_NONE if detection + * fails. The socket is always closed before returning. + */ cwist_rdbms_provider_t cwist_rdbms_probe_port(int port) { int sock = socket(AF_INET, SOCK_STREAM, 0); if (sock < 0) { @@ -127,6 +140,18 @@ cwist_rdbms_provider_t cwist_rdbms_probe_port(int port) { return CWIST_RDBMS_NONE; } +/** + * @brief Allocate and attach an RDBMS runtime record to an app. + * + * Idempotent: if @p app already has an RDBMS runtime mounted, this returns + * true without allocating. On success the new runtime is stored in + * @c app->rdbms with @c ready set to true. + * + * @param app App to attach the runtime to; must not be NULL. + * @param provider Detected provider stored in the runtime. + * @param port Port the provider was detected on. + * @return true if a runtime is mounted, false on NULL app or allocation failure. + */ bool cwist_rdbms_mount_runtime(cwist_app *app, cwist_rdbms_provider_t provider, int port) { if (!app) return false; @@ -151,6 +176,16 @@ bool cwist_rdbms_mount_runtime(cwist_app *app, cwist_rdbms_provider_t provider, return true; } +/** + * @brief Auto-detect an RDBMS on a local port and mount its runtime on an app. + * + * Probes @p port via cwist_rdbms_probe_port(); on success the detected + * provider's runtime is mounted with cwist_rdbms_mount_runtime(). + * + * @param app App to attach the runtime to; must not be NULL. + * @param port TCP port on localhost to probe. + * @return true if detection and mounting succeed, false otherwise. + */ bool cwist_app_auto_rdbms(cwist_app *app, int port) { if (!app) { CWIST_LOG_ERROR("RDBMS auto-mount: app is NULL"); diff --git a/src/core/seq/seq.c b/src/core/seq/seq.c index e2a6d8c14..16f41e30e 100644 --- a/src/core/seq/seq.c +++ b/src/core/seq/seq.c @@ -26,15 +26,40 @@ struct cwist_seq_assembler { /* Header helpers */ /* -------------------------------------------------------------------------- */ +/** + * @brief Read a big-endian 16-bit value from a byte buffer. + * @param p Pointer to at least 2 readable bytes. + * @return The decoded value in host byte order. + */ static uint16_t seq_read_u16(const uint8_t *p) { return (uint16_t)(((uint16_t)p[0] << 8) | (uint16_t)p[1]); } +/** + * @brief Write a 16-bit value to a byte buffer in big-endian order. + * @param p Pointer to at least 2 writable bytes. + * @param v Value to encode. + */ static void seq_write_u16(uint8_t *p, uint16_t v) { p[0] = (uint8_t)((v >> 8) & 0xff); p[1] = (uint8_t)(v & 0xff); } +/** + * @brief Parse and validate a sequenced chunk header. + * + * Reads the fixed-size chunk header from @p data and fills @p out. The payload + * pointer in @p out references memory inside @p data and is not copied. The + * chunk is rejected if the sequence pair (seq/total) is missing, out of range, + * or inconsistent, or if the payload length fields disagree with the buffer + * length. + * + * @param data Buffer containing at least CWIST_SEQ_HEADER_SIZE bytes. + * @param len Length of @p data in bytes. + * @param out Receives the parsed chunk fields on success. + * @retval true The chunk is valid and @p out is populated. + * @retval false Invalid arguments or a malformed chunk; @p out is untouched. + */ bool cwist_seq_chunk_parse(const uint8_t *data, size_t len, cwist_seq_chunk_t *out) { if (!data || !out || len < CWIST_SEQ_HEADER_SIZE) return false; @@ -56,6 +81,19 @@ bool cwist_seq_chunk_parse(const uint8_t *data, size_t len, cwist_seq_chunk_t *o return true; } +/** + * @brief Serialize a chunk header into an 8-byte buffer. + * + * Encodes @p seq, @p total, @p payload_len, and @p chunk_size as big-endian + * 16-bit fields. Performs no validation; callers must supply a consistent + * sequence pair (see cwist_seq_chunk_parse()). + * + * @param out Buffer of CWIST_SEQ_HEADER_SIZE bytes to write. + * @param seq Chunk sequence number (1-based). + * @param total Total number of chunks in the message. + * @param payload_len Payload bytes in this chunk. + * @param chunk_size Full payload size of every non-final chunk. + */ void cwist_seq_chunk_build_header(uint8_t out[CWIST_SEQ_HEADER_SIZE], uint16_t seq, uint16_t total, uint16_t payload_len, uint16_t chunk_size) { seq_write_u16(out + 0, seq); @@ -68,6 +106,22 @@ void cwist_seq_chunk_build_header(uint8_t out[CWIST_SEQ_HEADER_SIZE], uint16_t s /* Message splitter */ /* -------------------------------------------------------------------------- */ +/** + * @brief Split a message buffer into sequenced chunks. + * + * Allocates each chunk with cwist_alloc() and writes it into @p out; the + * result is released with cwist_seq_message_free(). Every chunk except the + * last carries exactly @p chunk_payload_size payload bytes. @p out is zeroed + * before use and again on failure. + * + * @param data Message bytes to split. + * @param len Length of @p data in bytes. + * @param chunk_payload_size Payload bytes per chunk; must be non-zero. + * @param out Receives the chunk array on success. + * @retval true @p out holds @p out->count chunks. + * @retval false Invalid arguments, or the message would exceed the chunk or + * reassembled-size limits, or allocation failed; @p out is zeroed. + */ bool cwist_seq_split(const uint8_t *data, size_t len, uint16_t chunk_payload_size, cwist_seq_message_t *out) { if (!data || len == 0 || chunk_payload_size == 0 || !out) return false; @@ -113,6 +167,14 @@ bool cwist_seq_split(const uint8_t *data, size_t len, uint16_t chunk_payload_siz return true; } +/** + * @brief Free all memory owned by a cwist_seq_message_t. + * + * Releases each chunk buffer plus the chunk and length arrays, then zeroes + * the struct so it is safe to reuse. Does nothing if @p msg is NULL. + * + * @param msg Message to free; may be NULL. + */ void cwist_seq_message_free(cwist_seq_message_t *msg) { if (!msg) return; if (msg->chunks) { @@ -127,10 +189,25 @@ void cwist_seq_message_free(cwist_seq_message_t *msg) { /* Assembler */ /* -------------------------------------------------------------------------- */ +/** + * @brief Create an assembler with no reassembled-size limit. + * @return New assembler, or NULL on allocation failure. + */ cwist_seq_assembler_t *cwist_seq_assembler_create(void) { return cwist_seq_assembler_create_limited(0); } +/** + * @brief Create an assembler with a reassembled-size limit. + * + * Allocates an empty assembler state; buffers are allocated lazily on the + * first cwist_seq_assembler_feed() call. Destroy with + * cwist_seq_assembler_destroy(). + * + * @param max_data_len Maximum accepted reassembled size in bytes, or 0 to use + * the CWIST_SEQ_MAX_REASSEMBLED_SIZE default. + * @return New assembler, or NULL on allocation failure. + */ cwist_seq_assembler_t *cwist_seq_assembler_create_limited(size_t max_data_len) { cwist_seq_assembler_t *a = (cwist_seq_assembler_t *)cwist_alloc(sizeof(*a)); if (!a) return NULL; @@ -139,6 +216,10 @@ cwist_seq_assembler_t *cwist_seq_assembler_create_limited(size_t max_data_len) { return a; } +/** + * @brief Destroy an assembler and free all its buffers. + * @param a Assembler to destroy; may be NULL. + */ void cwist_seq_assembler_destroy(cwist_seq_assembler_t *a) { if (!a) return; cwist_free(a->data); @@ -147,6 +228,15 @@ void cwist_seq_assembler_destroy(cwist_seq_assembler_t *a) { cwist_free(a); } +/** + * @brief Discard all received state, keeping the size limit. + * + * Frees the reassembly buffers but retains the max_data_len configured at + * creation so the assembler can be reused for a new message. Does nothing if + * @p a is NULL. + * + * @param a Assembler to reset; may be NULL. + */ void cwist_seq_assembler_reset(cwist_seq_assembler_t *a) { if (!a) return; size_t max_data_len = a->max_data_len; @@ -157,6 +247,24 @@ void cwist_seq_assembler_reset(cwist_seq_assembler_t *a) { a->max_data_len = max_data_len; } +/** + * @brief Feed one sequenced chunk into the assembler. + * + * The first valid chunk establishes the message's total and chunk_size; + * subsequent chunks must match them or are rejected. Identical duplicates are + * accepted idempotently; a conflicting duplicate is flagged as contamination + * (retaining the original bytes) and never overwrites good data. The total + * message length is determined only when the final chunk (seq == total) + * arrives, so reordered delivery is tolerated. On allocation failure all + * state is cleared and false is returned. + * + * @param a Assembler to feed. + * @param chunk Parsed chunk to accept; payload bytes are copied into the + * reassembly buffer. + * @retval true The chunk was accepted (new data or an identical duplicate). + * @retval false Invalid or inconsistent chunk, conflicting duplicate + * (contamination), size-limit exceeded, or allocation failure. + */ bool cwist_seq_assembler_feed(cwist_seq_assembler_t *a, const cwist_seq_chunk_t *chunk) { if (!a || !chunk) return false; @@ -221,11 +329,36 @@ bool cwist_seq_assembler_feed(cwist_seq_assembler_t *a, const cwist_seq_chunk_t return true; } +/** + * @brief Check whether all chunks of the message have been received intact. + * + * Completion requires every chunk to be present, no conflicting duplicates + * (contamination), and the final chunk to have arrived so the total length is + * known. + * + * @param a Assembler to query; may be NULL. + * @retval true The message is fully reassembled and clean. + * @retval false Not complete, or @p a is NULL. + */ bool cwist_seq_assembler_is_complete(const cwist_seq_assembler_t *a) { return a && a->have_state && !a->contaminated && a->received_count == a->total && a->received[a->total - 1] && a->total_len > 0; } +/** + * @brief List the sequence numbers of missing chunks. + * + * Returns the count of missing chunks and, when @p out has capacity, writes + * their 1-based sequence numbers in ascending order. Returns 0 if the + * assembler has no state or the message is already complete; in those cases + * nothing is written to @p out. + * + * @param a Assembler to query; may be NULL. + * @param out Optional array to receive missing sequence numbers. + * @param out_cap Number of elements @p out can hold; ignored when @p out is + * NULL. + * @return Total number of missing chunks (may exceed @p out_cap). + */ size_t cwist_seq_assembler_recovery_targets(const cwist_seq_assembler_t *a, uint16_t *out, size_t out_cap) { if (!a || !a->have_state || cwist_seq_assembler_is_complete(a)) return 0; @@ -240,6 +373,20 @@ size_t cwist_seq_assembler_recovery_targets(const cwist_seq_assembler_t *a, uint return missing; } +/** + * @brief Access the fully reassembled message. + * + * Succeeds only when cwist_seq_assembler_is_complete() is true. The returned + * pointer refers to the assembler's internal buffer, which remains valid + * until the assembler is reset, destroyed, or fed another chunk; ownership + * stays with the assembler. + * + * @param a Assembler to query. + * @param out_data Receives a pointer to the reassembled bytes. + * @param out_len Receives the reassembled length in bytes. + * @retval true The message is complete; outputs are populated. + * @retval false The message is incomplete or an output pointer is NULL. + */ bool cwist_seq_assembler_get_data(cwist_seq_assembler_t *a, const uint8_t **out_data, size_t *out_len) { if (!cwist_seq_assembler_is_complete(a) || !out_data || !out_len) return false; diff --git a/src/core/seq/seq_auth.c b/src/core/seq/seq_auth.c index 60e575c84..c1f2279b9 100644 --- a/src/core/seq/seq_auth.c +++ b/src/core/seq/seq_auth.c @@ -25,6 +25,18 @@ #define SEQ_AUTH_WASM_CRYPTO 1 #define SEQ_AUTH_TAG_CAP CWIST_SHA256_DIGEST_LEN /* Constant-time compare + secure cleanse without OpenSSL. */ +/** + * @brief Constant-time memory comparison (WASM builds only). + * + * Computes a bitwise OR of the byte-wise XOR differences, so timing does not + * reveal where the first mismatch occurs. Used in place of CRYPTO_memcmp on + * Emscripten/WASI. + * + * @param a First buffer. + * @param b Second buffer. + * @param len Number of bytes to compare. + * @return 0 if the buffers are identical, non-zero otherwise. + */ static int seq_auth_memcmp(const uint8_t *a, const uint8_t *b, size_t len) { uint8_t diff = 0; for (size_t i = 0; i < len; i++) diff |= (uint8_t)(a[i] ^ b[i]); @@ -61,10 +73,31 @@ struct cwist_seq_auth_context { pthread_mutex_t lock; }; +/** + * @brief Read a big-endian 16-bit value from a byte buffer. + * + * @param p Pointer to at least 2 bytes. + * @return The value in host order. + */ static uint16_t read_u16(const uint8_t *p) { return (uint16_t)(((uint16_t)p[0] << 8) | p[1]); } +/** + * @brief Validate an authenticated chunk and split it into header and payload. + * + * Parses the fixed sequence header fields and checks their consistency + * (non-zero seq/total, seq <= total, payload_len <= chunk_size, interior + * chunks full size) as well as that the buffer actually contains the + * declared payload. + * + * @param data Buffer holding the authenticated chunk (header + tag + payload). + * @param len Length of @p data in bytes. + * @param out Receives the parsed chunk fields and a pointer to the payload + * inside @p data; untouched on failure. + * @retval true The chunk is well-formed and @p out was populated. + * @retval false Invalid arguments or malformed/inconsistent header. + */ static bool seq_auth_chunk_valid(const uint8_t *data, size_t len, cwist_seq_chunk_t *out) { if (!data || len < CWIST_SEQ_HEADER_SIZE || !out) return false; out->seq = read_u16(data); @@ -80,6 +113,24 @@ static bool seq_auth_chunk_valid(const uint8_t *data, size_t len, cwist_seq_chun return true; } +/** + * @brief Compute the HMAC-SHA256 authentication tag for a chunk. + * + * Authenticates session_id || header_without_tag || payload under the + * context key. Uses the bundled streaming SHA-256 on WASM builds and + * OpenSSL HMAC elsewhere; intermediate key material is wiped before + * returning on all paths. + * + * @param ctx Authenticated-sequencing context holding the key. + * @param header_without_tag Header bytes (sequence header + message id + + * nonce), tag excluded. + * @param header_len Length of @p header_without_tag. + * @param payload Chunk payload bytes. + * @param payload_len Length of @p payload. + * @param tag Output buffer of SEQ_AUTH_TAG_CAP bytes. + * @retval true Tag computed successfully. + * @retval false HMAC initialization/computation failed. + */ static bool auth_tag(const cwist_seq_auth_context_t *ctx, const uint8_t *header_without_tag, size_t header_len, const uint8_t *payload, size_t payload_len, uint8_t tag[SEQ_AUTH_TAG_CAP]) { @@ -123,12 +174,38 @@ static bool auth_tag(const cwist_seq_auth_context_t *ctx, const uint8_t *header_ #endif } +/** + * @brief Check whether a fixed-width value is present in a replay cache. + * + * Comparison uses the constant-time compare macro. The cache is a flat + * array of @p count valid entries, each @p width bytes wide. + * + * @param cache Flat array of cached values. + * @param count Number of valid entries in @p cache. + * @param width Byte width of each entry. + * @param value Value to look up. + * @retval true @p value is present in the cache. + * @retval false Not present. + */ static bool cache_contains(const uint8_t *cache, size_t count, size_t width, const uint8_t *value) { for (size_t i = 0; i < count; ++i) if (SEQ_AUTH_MEMCMP(cache + i * width, value, width) == 0) return true; return false; } +/** + * @brief Append a value to a fixed-size circular replay cache. + * + * Overwrites the oldest slot once the cache is full. Callers must hold the + * context lock when used on a shared context. + * + * @param cache Flat array with room for @p capacity entries of @p width bytes. + * @param count In/out valid-entry count, incremented until it reaches capacity. + * @param next In/out write cursor, advanced modulo @p capacity. + * @param capacity Total number of slots in @p cache. + * @param width Byte width of each entry. + * @param value Entry to copy into the cache. + */ static void cache_add(uint8_t *cache, size_t *count, size_t *next, size_t capacity, size_t width, const uint8_t *value) { memcpy(cache + *next * width, value, width); @@ -136,6 +213,22 @@ static void cache_add(uint8_t *cache, size_t *count, size_t *next, size_t capaci if (*count < capacity) (*count)++; } +/** + * @brief Create an authenticated-sequencing context. + * + * Allocates the context plus the nonce and completed-message-id replay + * caches, each sized to @p replay_window entries. On failure all partially + * allocated state is freed and NULL is returned. The returned context is + * owned by the caller and must be released with + * cwist_seq_auth_context_destroy(). + * + * @param key 32-byte authentication key, copied into the context. + * @param session_id 16-byte session identifier, copied into the context. + * @param replay_window Number of nonces/message ids to remember for replay + * detection; must be non-zero and small enough that + * window * 28 does not overflow size_t. + * @return New context, or NULL on invalid arguments or allocation failure. + */ cwist_seq_auth_context_t * cwist_seq_auth_context_create(const uint8_t key[CWIST_SEQ_AUTH_KEY_SIZE], const uint8_t session_id[CWIST_SEQ_AUTH_SESSION_ID_SIZE], @@ -160,6 +253,14 @@ cwist_seq_auth_context_create(const uint8_t key[CWIST_SEQ_AUTH_KEY_SIZE], return ctx; } +/** + * @brief Destroy an authenticated-sequencing context. + * + * Wipes the key and session id, destroys the mutex, and frees the replay + * caches and the context itself. Safe to call with NULL. + * + * @param ctx Context to release, or NULL. + */ void cwist_seq_auth_context_destroy(cwist_seq_auth_context_t *ctx) { if (!ctx) return; SEQ_AUTH_CLEANSE(ctx->key, sizeof(ctx->key)); @@ -170,6 +271,17 @@ void cwist_seq_auth_context_destroy(cwist_seq_auth_context_t *ctx) { cwist_free(ctx); } +/** + * @brief Fill a buffer with cryptographically secure random bytes. + * + * Source is crypto.getRandomValues on Emscripten, getentropy under WASI, + * and OpenSSL RAND_bytes elsewhere. + * + * @param out Output buffer. + * @param len Number of bytes to write; must be non-zero and at most INT_MAX. + * @retval true The buffer was filled successfully. + * @retval false Invalid arguments or the random source failed. + */ bool cwist_seq_auth_random(uint8_t *out, size_t len) { if (!out || len == 0 || len > INT_MAX) return false; #if defined(__EMSCRIPTEN__) @@ -181,6 +293,26 @@ bool cwist_seq_auth_random(uint8_t *out, size_t len) { #endif } +/** + * @brief Attach message id, nonce, and authentication tag to a chunk. + * + * Builds the wire-format authenticated chunk (sequence header || message id + * || nonce || payload || tag) without modifying the caller's chunk. The tag + * is an HMAC-SHA256 truncated to CWIST_SEQ_AUTH_TAG_SIZE over the + * session id and the header/payload contents. The output buffer is owned by + * the caller and must be freed with cwist_free(). + * + * @param ctx Authenticated-sequencing context. + * @param message_id 16-byte message identifier to embed. + * @param nonce 12-byte nonce to embed. + * @param chunk Buffer holding one sequence chunk (header + payload). + * @param chunk_len Length of @p chunk in bytes. + * @param out Receives a newly allocated wire-format chunk on success. + * @param out_len Receives the wire chunk length on success. + * @retval true The chunk was authenticated and wrapped. + * @retval false Invalid arguments, malformed chunk, allocation failure, or + * tag computation failure; no output is produced. + */ bool cwist_seq_auth_wrap(const cwist_seq_auth_context_t *ctx, const uint8_t message_id[CWIST_SEQ_AUTH_MESSAGE_ID_SIZE], const uint8_t nonce[CWIST_SEQ_AUTH_NONCE_SIZE], const uint8_t *chunk, @@ -213,6 +345,25 @@ bool cwist_seq_auth_wrap(const cwist_seq_auth_context_t *ctx, return true; } +/** + * @brief Verify, authenticate, and split an incoming authenticated chunk. + * + * Validates the chunk layout, recomputes the HMAC-SHA256 tag (compared in + * constant time), and rejects replays by checking the nonce and message id + * against the context's replay caches under the context lock; a fresh nonce + * is recorded in the cache on success. Chunks of fully completed messages + * can be rejected by recording their ids with cwist_seq_auth_mark_complete(). + * + * @param ctx Authenticated-sequencing context. + * @param data Wire-format authenticated chunk. + * @param len Length of @p data in bytes. + * @param metadata Receives the verified message id and nonce. + * @param chunk Receives the parsed chunk header and a payload pointer into + * @p data; untouched on failure. + * @retval true The tag is valid and the chunk is not a replay. + * @retval false Invalid arguments, malformed chunk, bad tag, or replayed + * nonce/message id. + */ bool cwist_seq_auth_unwrap(cwist_seq_auth_context_t *ctx, const uint8_t *data, size_t len, cwist_seq_auth_metadata_t *metadata, cwist_seq_chunk_t *chunk) { cwist_seq_chunk_t parsed; @@ -247,6 +398,16 @@ bool cwist_seq_auth_unwrap(cwist_seq_auth_context_t *ctx, const uint8_t *data, s return true; } +/** + * @brief Record a message id as fully received. + * + * Adds the message id to the completed-message replay cache so any later + * chunk claiming that id is rejected by cwist_seq_auth_unwrap(). Duplicate + * ids are ignored. Safe to call with NULL; otherwise takes the context lock. + * + * @param ctx Authenticated-sequencing context, or NULL. + * @param message_id 16-byte message id to record. + */ void cwist_seq_auth_mark_complete(cwist_seq_auth_context_t *ctx, const uint8_t message_id[CWIST_SEQ_AUTH_MESSAGE_ID_SIZE]) { if (!ctx || !message_id) return; diff --git a/src/core/utils/json_builder.c b/src/core/utils/json_builder.c index 50805dd98..281cb1b80 100644 --- a/src/core/utils/json_builder.c +++ b/src/core/utils/json_builder.c @@ -43,12 +43,34 @@ void cwist_json_builder_destroy(cwist_json_builder *b) { } } +/** + * @brief Append a comma separator when the builder is ready for the next value. + * + * Emits a comma only when the previous value or container was closed + * (needs_comma set). Leaves the flag unchanged; callers reset it after + * writing their value. + * + * @param b Builder whose buffer receives the comma. NULL or an invalid + * buffer is ignored. + */ static void append_comma_if_needed(cwist_json_builder *b) { if (b && b->buffer && b->needs_comma) { cwist_sstring_append(b->buffer, ","); } } +/** + * @brief Append a string to the buffer as a JSON string literal. + * + * Surrounds the payload with double quotes and escapes the mandatory JSON + * control characters (quote, backslash, backspace, form feed, newline, + * carriage return, tab). Remaining bytes below 0x20 are emitted as + * \u00XX sequences; all other bytes pass through unmodified, so multi-byte + * (e.g. UTF-8) content is not validated or re-encoded. + * + * @param buf Target string buffer. Assumed valid. + * @param str Payload to escape. NULL appends nothing. + */ static void append_json_escaped_str(cwist_sstring *buf, const char *str) { if (!str) return; cwist_sstring_append(buf, "\""); @@ -96,6 +118,16 @@ static void append_json_escaped_str(cwist_sstring *buf, const char *str) { cwist_sstring_append(buf, "\""); } +/** + * @brief Append an escaped object member key followed by a colon separator. + * + * Does nothing when key is NULL, allowing callers to treat NULL as "array + * element" without a separate branch. + * + * @param b Builder whose buffer receives the key. Assumed valid with a + * valid buffer. + * @param key Member name to emit, or NULL to omit the key entirely. + */ static void append_key_if_present(cwist_json_builder *b, const char *key) { if (key) { append_json_escaped_str(b->buffer, key); diff --git a/src/core/utils/json_heal.c b/src/core/utils/json_heal.c index 0609991fa..4106624a4 100644 --- a/src/core/utils/json_heal.c +++ b/src/core/utils/json_heal.c @@ -23,6 +23,13 @@ typedef struct { size_t cap; } strbuf_t; +/** + * @brief Initialise a heap-backed dynamic string buffer. + * @param b Buffer to initialise; must not be NULL. + * @param init_cap Initial capacity in bytes (one extra byte is reserved for the NUL terminator). + * @retval true Buffer allocated and initialised. + * @retval false Allocation failure; @p b is left uninitialised. + */ static bool strbuf_init(strbuf_t *b, size_t init_cap) { b->data = (char *)cwist_alloc(init_cap + 1); if (!b->data) return false; @@ -32,6 +39,13 @@ static bool strbuf_init(strbuf_t *b, size_t init_cap) { return true; } +/** + * @brief Append a single character to the buffer, growing it if needed. + * @param b Buffer to append to; must be initialised. + * @param c Character to append (the NUL terminator is maintained automatically). + * @retval true Character appended (possibly after reallocation). + * @retval false Reallocation failure; buffer contents remain valid but @p c was not appended. + */ static bool strbuf_push(strbuf_t *b, char c) { if (b->len >= b->cap) { size_t new_cap = b->cap * 2 + 64; @@ -45,6 +59,10 @@ static bool strbuf_push(strbuf_t *b, char c) { return true; } +/** + * @brief Release the buffer's backing allocation and reset it to empty state. + * @param b Buffer to release; safe to call on an already-freed or never-initialised buffer. + */ static void strbuf_free(strbuf_t *b) { if (b->data) { cwist_free(b->data); @@ -57,6 +75,12 @@ static void strbuf_free(strbuf_t *b) { * Log helper * ======================================================================== */ +/** + * @brief Append a message to a NUL-terminated log buffer, truncating at its end. + * @param log Log buffer; NULL or a zero size is treated as no-op (log disabled). + * @param log_sz Total size of @p log in bytes. + * @param msg Message to append; silently dropped when the buffer is already (near) full. + */ static void log_append(char *log, size_t log_sz, const char *msg) { if (!log || log_sz == 0) return; size_t cur = strlen(log); @@ -75,7 +99,13 @@ static void log_append(char *log, size_t log_sz, const char *msg) { * 5. Attempt cJSON_Parse; return NULL if it still fails. * ======================================================================== */ -/* Remove trailing commas: ",]" → "]" / ",}" → "}" */ +/** + * @brief Remove trailing commas (",]" / ",}") inside a string buffer in place. + * + * String literals and escape sequences are respected: a comma immediately + * preceding a ']' or '}' outside a string is deleted. + * @param b Buffer holding the JSON text; its length is updated in place. + */ static void remove_trailing_commas(strbuf_t *b) { char *d = b->data; size_t len = b->len; @@ -114,7 +144,16 @@ static void remove_trailing_commas(strbuf_t *b) { } } -/* Append missing closing brackets / braces at the end of the buffer. */ +/** + * @brief Append missing closing brackets / braces at the end of the buffer. + * + * Opens seen outside string literals are pushed on a scratch-allocated stack; + * unmatched closers are ignored (left for the parser to flag). When closers + * are appended, a note is recorded in the log. + * @param b Buffer holding the JSON text. + * @param log Optional recovery log (NULL-safe). + * @param log_sz Size of @p log in bytes. + */ static void balance_brackets(strbuf_t *b, char *log, size_t log_sz) { cwist_scratch_t stack_s CWIST_SCRATCH_DEFER = {0}; char *stack = (char *)cwist_scratch_alloc(&stack_s, b->len + 1); @@ -164,7 +203,19 @@ static void balance_brackets(strbuf_t *b, char *log, size_t log_sz) { while (top > 0) strbuf_push(b, stack[--top]); } -/* Returns a heap-allocated (cwist_alloc) fixed string, or NULL on failure. */ +/** + * @brief Attempt L1 syntax repair of malformed JSON text. + * + * Applies, in order: BOM stripping, removal of // line comments outside + * strings, bracket balancing, and trailing-comma removal, then verifies the + * result with cJSON_Parse. + * @param input Raw JSON text; NULL is rejected. + * @param log Optional recovery log (NULL-safe). + * @param log_sz Size of @p log in bytes. + * @return Heap-allocated (cwist_alloc) NUL-terminated repaired string owned by + * the caller, or NULL if input is NULL, allocation fails, or the + * repaired text still does not parse. + */ static char *l1_heal(const char *input, char *log, size_t log_sz) { if (!input) return NULL; size_t in_len = strlen(input); @@ -238,7 +289,15 @@ static char *l1_heal(const char *input, char *log, size_t log_sz) { * L2 – Schema alignment (field renaming + type coercion) * ======================================================================== */ -/* Normalise: lower-case, strip _ and - for fuzzy comparison. */ +/** + * @brief Normalise a field name for fuzzy comparison. + * + * Lower-cases the name and drops '_' and '-' separators, writing at most + * dst_sz - 1 characters plus a NUL terminator. + * @param src Source name; must not be NULL. + * @param dst Destination buffer; must not be NULL. + * @param dst_sz Size of @p dst in bytes. + */ static void normalise_name(const char *src, char *dst, size_t dst_sz) { size_t j = 0; for (size_t i = 0; src[i] && j < dst_sz - 1; i++) { @@ -249,6 +308,13 @@ static void normalise_name(const char *src, char *dst, size_t dst_sz) { dst[j] = '\0'; } +/** + * @brief Compare two field names fuzzily (case-insensitive, ignoring '_'/'-'). + * @param a First name. + * @param b Second name. + * @retval true The normalised forms are identical. + * @retval false The normalised forms differ. + */ static bool names_fuzzy_match(const char *a, const char *b) { char na[64], nb[64]; normalise_name(a, na, sizeof(na)); @@ -256,7 +322,13 @@ static bool names_fuzzy_match(const char *a, const char *b) { return strcmp(na, nb) == 0; } -/* Find a cJSON child whose key fuzzy-matches `name`. NULL if not found. */ +/** + * @brief Find a cJSON child whose key fuzzy-matches `name`. + * @param obj cJSON object to search; must be an object node. + * @param name Name to fuzzy-match against each child's key. + * @param found_key Optional output: pointer to the actual child key that matched. + * @return The matching child item, or NULL if no child matches. + */ static cJSON *find_fuzzy(cJSON *obj, const char *name, const char **found_key) { cJSON *child = obj->child; while (child) { @@ -269,6 +341,20 @@ static cJSON *find_fuzzy(cJSON *obj, const char *name, const char **found_key) { return NULL; } +/** + * @brief Align a parsed JSON object with a schema in place (L2 stage). + * + * For each schema field, locates the corresponding object member by canonical + * name, explicit alias, or fuzzy match; renames it to the canonical name when + * found under another key, and coerces its value to the schema's declared type + * (string/number/bool conversions). All edits and descriptions of each edit + * are applied directly to @p obj and @p log. + * @param obj cJSON object to align; must be an object node. + * @param schema Field schema to enforce. + * @param log Optional recovery log (NULL-safe). + * @param log_sz Size of @p log in bytes. + * @return Number of changes (renames + coercions) applied; negative on invalid arguments. + */ int cwist_json_schema_align(cJSON *obj, const cwist_schema_t *schema, char *log, size_t log_sz) { if (!obj || !schema || !cJSON_IsObject(obj)) return -1; diff --git a/src/core/validation/bind.c b/src/core/validation/bind.c index 9fa859634..4561ff90d 100644 --- a/src/core/validation/bind.c +++ b/src/core/validation/bind.c @@ -417,18 +417,59 @@ static bool cwist_bind_generic(const cwist_bind_schema_t *schema, const char *pa * Public implementation * ---------------------------------------------------------------------- */ +/** + * @brief Validate and bind a JSON request body into a struct per the schema. + * + * Each schema field is looked up in the parsed JSON body, validated against + * its rules, and converted into the struct field at @p f->target_offset. + * The output struct is written even for fields with no matching key (zero + * values are written for missing fields). + * + * @param req HTTP request whose body contains JSON text; may be NULL. + * @param schema Declarative field schema; must not be NULL. + * @param out Destination struct; must not be NULL. + * @param result Accumulator for validation errors; reset at start. + * @return true when every field passed validation, false otherwise. + * @retval true Result is valid and @p out is fully written. + * @retval false @p result contains one or more field errors. + */ bool cwist_app_req_bind_json(cwist_http_request *req, const cwist_bind_schema_t *schema, void *out, cwist_bind_result_t *result) { const char *payload = (req && req->body && req->body->data) ? req->body->data : NULL; return cwist_bind_generic(schema, payload, true, out, result); } +/** + * @brief Validate and bind a form-encoded (key=value) request body into a struct per the schema. + * + * Uses a simple key=value scan of the body without URL unescaping; values are + * bounded to an internal buffer before validation. + * + * @param req HTTP request whose body contains form text; may be NULL. + * @param schema Declarative field schema; must not be NULL. + * @param out Destination struct; must not be NULL. + * @param result Accumulator for validation errors; reset at start. + * @return true when every field passed validation, false otherwise. + * @retval true Result is valid and @p out is fully written. + * @retval false @p result contains one or more field errors. + */ bool cwist_app_req_bind_form(cwist_http_request *req, const cwist_bind_schema_t *schema, void *out, cwist_bind_result_t *result) { const char *payload = (req && req->body && req->body->data) ? req->body->data : NULL; return cwist_bind_generic(schema, payload, false, out, result); } +/** + * @brief Write a 400 Bad Request JSON error response from a bind result. + * + * Serializes the accumulated field errors as a JSON object + * `{ "success": false, "errors": [ { "field", "message" }, ... ] }`, + * sets the response status and Content-Type, and takes ownership of freeing + * the temporary JSON builder. + * + * @param res Response to fill in; ignored when NULL. + * @param result Bind result containing the errors; ignored when NULL. + */ void cwist_bind_write_error_response(cwist_http_response *res, const cwist_bind_result_t *result) { if (!res || !result) return; res->status_code = CWIST_HTTP_BAD_REQUEST; @@ -453,6 +494,21 @@ void cwist_bind_write_error_response(cwist_http_response *res, const cwist_bind_ cwist_json_builder_destroy(jb); } +/** + * @brief Bind a JSON request body, writing a 400 error response on failure. + * + * Convenience wrapper around cwist_app_req_bind_json() and + * cwist_bind_write_error_response() for handlers that want automatic + * error replies. + * + * @param req HTTP request whose body contains JSON text; may be NULL. + * @param res Response receiving the error payload when binding fails. + * @param schema Declarative field schema; must not be NULL. + * @param out Destination struct; must not be NULL. + * @return true when binding succeeded; false when an error response was written. + * @retval true @p out is fully written, @p res untouched. + * @retval false A 400 error response was written to @p res. + */ bool cwist_app_req_bind_json_or_400(cwist_http_request *req, cwist_http_response *res, const cwist_bind_schema_t *schema, void *out) { cwist_bind_result_t result; diff --git a/src/net/graphql/graphql.c b/src/net/graphql/graphql.c index f91d1c511..b8f1a9e6f 100644 --- a/src/net/graphql/graphql.c +++ b/src/net/graphql/graphql.c @@ -20,15 +20,34 @@ struct cwist_graphql_schema { graphql_field_t *mutations; }; +/** + * @brief Wrap an integer status code in a cwist_error_t tagged as CWIST_ERR_INT16. + * @param code Integer status to encode (0 on success, negative on failure). + * @return Error value carrying @p code in its err_i16 member. + */ static cwist_error_t gql_error(int code) { return (cwist_error_t){.errtype = CWIST_ERR_INT16, .error.err_i16 = code}; } +/** + * @brief Test whether a character is valid in a GraphQL name at a given position. + * @param c Character to test. + * @param first True when testing the leading character of a name (only '_' or a + * letter are allowed there). + * @retval true The character is allowed at that position. + * @retval false The character is not allowed. + */ static bool gql_name_char(char c, bool first) { if (first) return (c == '_' || isalpha((unsigned char)c)); return (c == '_' || isalnum((unsigned char)c)); } +/** + * @brief Validate that a C string conforms to the GraphQL Name production. + * @param s String to validate (may be NULL). + * @retval true The string is a non-empty, fully valid GraphQL name. + * @retval false The string is NULL, empty, or contains invalid characters. + */ static bool gql_name_valid(const char *s) { if (!s || !gql_name_char(*s, true)) return false; for (++s; *s; ++s) { @@ -37,6 +56,12 @@ static bool gql_name_valid(const char *s) { return true; } +/** + * @brief Append an error object {"message": ...} to a cJSON errors array. + * @details Silently does nothing if the error object cannot be allocated. + * @param errors cJSON array receiving the error entry. + * @param message Human-readable error message text (borrowed, copied into the array). + */ static void gql_add_error(cJSON *errors, const char *message) { cJSON *e = cJSON_CreateObject(); if (e) { @@ -45,6 +70,12 @@ static void gql_add_error(cJSON *errors, const char *message) { } } +/** + * @brief Allocate an empty GraphQL schema with no query or mutation fields. + * @return Newly allocated schema on success, NULL on allocation failure. + * The caller owns the result and must free it with + * cwist_graphql_schema_destroy(). + */ cwist_graphql_schema_t *cwist_graphql_schema_create(void) { cwist_graphql_schema_t *schema = (cwist_graphql_schema_t *)cwist_alloc(sizeof(*schema)); if (schema) { @@ -54,6 +85,10 @@ cwist_graphql_schema_t *cwist_graphql_schema_create(void) { return schema; } +/** + * @brief Free a linked list of graphql_field_t nodes and their name strings. + * @param f Head of the list (may be NULL). + */ static void free_fields(graphql_field_t *f) { while (f) { graphql_field_t *n = f->next; @@ -63,6 +98,10 @@ static void free_fields(graphql_field_t *f) { } } +/** + * @brief Destroy a schema and all registered query/mutation fields. + * @param schema Schema to free (may be NULL). + */ void cwist_graphql_schema_destroy(cwist_graphql_schema_t *schema) { if (!schema) return; free_fields(schema->queries); @@ -70,6 +109,18 @@ void cwist_graphql_schema_destroy(cwist_graphql_schema_t *schema) { cwist_free(schema); } +/** + * @brief Insert or update a resolver field in a schema field list. + * @details If a field with the same name already exists, its resolver and + * context are replaced in place; otherwise a new node is prepended. + * Field names are validated as GraphQL names. + * @param head Pointer to the list head pointer. + * @param field GraphQL field name. + * @param resolver Resolver callback invoked when the field is queried. + * @param ctx Opaque pointer passed to the resolver. + * @retval true The field was registered (or updated). + * @retval false Invalid argument, invalid field name, or allocation failure. + */ static bool add_field(graphql_field_t **head, const char *field, cwist_graphql_resolver_fn resolver, void *ctx) { if (!head || !gql_name_valid(field) || !resolver) return false; @@ -94,18 +145,42 @@ static bool add_field(graphql_field_t **head, const char *field, cwist_graphql_r return true; } +/** + * @brief Register a resolver for a GraphQL query field. + * @param schema Schema to register the field on. + * @param field Query field name (must be a valid GraphQL name). + * @param resolver Resolver callback invoked when the field is queried. + * @param ctx Opaque pointer passed to the resolver. + * @retval true The field was registered (or its resolver updated). + * @retval false NULL schema or invalid field/resolver. + */ bool cwist_graphql_add_query(cwist_graphql_schema_t *schema, const char *field, cwist_graphql_resolver_fn resolver, void *ctx) { if (!schema) return false; return add_field(&schema->queries, field, resolver, ctx); } +/** + * @brief Register a resolver for a GraphQL mutation field. + * @param schema Schema to register the field on. + * @param field Mutation field name (must be a valid GraphQL name). + * @param resolver Resolver callback invoked when the field is mutated. + * @param ctx Opaque pointer passed to the resolver. + * @retval true The field was registered (or its resolver updated). + * @retval false NULL schema or invalid field/resolver. + */ bool cwist_graphql_add_mutation(cwist_graphql_schema_t *schema, const char *field, cwist_graphql_resolver_fn resolver, void *ctx) { if (!schema) return false; return add_field(&schema->mutations, field, resolver, ctx); } +/** + * @brief Look up a field by name in a schema field list. + * @param head Head of the field list. + * @param name Field name to find. + * @return Matching field node, or NULL if not present. + */ static graphql_field_t *gql_lookup(graphql_field_t *head, const char *name) { for (graphql_field_t *f = head; f; f = f->next) { if (strcmp(f->name, name) == 0) return f; @@ -113,6 +188,11 @@ static graphql_field_t *gql_lookup(graphql_field_t *head, const char *name) { return NULL; } +/** + * @brief Advance a parse cursor past whitespace, commas, and GraphQL '#' comments. + * @param p Current cursor position in the query text. + * @return Pointer to the first character that is not insignificant. + */ static const char *skip_ws_comments(const char *p) { while (*p) { if (isspace((unsigned char)*p) || *p == ',') { @@ -126,7 +206,18 @@ static const char *skip_ws_comments(const char *p) { return p; } -/* Parse GraphQL arguments: ( arg1: "val", arg2: 123, arg3: $varName ) */ +/** + * @brief Parse GraphQL arguments: ( arg1: "val", arg2: 123, arg3: $varName ) + * @details Strings, numbers, booleans, null, and variable references are + * supported. Parse failures are appended to @p errors and leave + * *out_args as NULL. + * @param p Cursor positioned at or before the '('. + * @param variables Optional cJSON object supplying variable values. + * @param out_args Receives a newly allocated cJSON object with parsed + * arguments (NULL if the field has no argument list). + * @param errors cJSON array receiving parse error entries. + * @return Advanced cursor past the closing ')'. + */ static const char *parse_arguments(const char *p, const cJSON *variables, cJSON **out_args, cJSON *errors) { *out_args = NULL; @@ -239,7 +330,20 @@ static const char *parse_arguments(const char *p, const cJSON *variables, cJSON return p; } -/* Parse and execute field selections recursively */ +/** + * @brief Parse and execute field selections recursively. + * @details Each selected field is resolved through @p schema_fields and its + * value added to @p parent_data. Recursion depth and field count are + * bounded by CWIST_GRAPHQL_MAX_DEPTH / CWIST_GRAPHQL_MAX_FIELDS, and + * unknown fields are reported in @p errors. + * @param p Cursor positioned at '{'. + * @param schema_fields Field list used to resolve selections. + * @param variables Optional cJSON object of variable values. + * @param parent_data cJSON object receiving resolved field values. + * @param errors cJSON array receiving error entries. + * @param depth Current recursion depth (1 for the root selection set). + * @return Advanced cursor past the closing '}'. + */ static const char *parse_selection_set(const char *p, graphql_field_t *schema_fields, const cJSON *variables, cJSON *parent_data, cJSON *errors, unsigned int depth) { @@ -348,6 +452,18 @@ static const char *parse_selection_set(const char *p, graphql_field_t *schema_fi return p; } +/** + * @brief Execute a GraphQL query or mutation from a JSON request body. + * @details The request must be a JSON object with a string "query" member and + * an optional "variables" object. On success *out_json receives a + * compact JSON response of the form {"data": {...}} with an "errors" + * array added when resolution produced errors. Ownership of + * *out_json transfers to the caller. + * @param schema Schema providing the query/mutation fields. + * @param request_json JSON-encoded GraphQL request text. + * @param out_json Receives the response body string on success. + * @return 0 on success, non-zero on invalid arguments or internal failure. + */ cwist_error_t cwist_graphql_execute(cwist_graphql_schema_t *schema, const char *request_json, cwist_sstring **out_json) { if (!schema || !request_json || !out_json) return gql_error(-1); @@ -422,11 +538,22 @@ cwist_error_t cwist_graphql_execute(cwist_graphql_schema_t *schema, const char * return gql_error(0); } -/* Extract the root field name and parsed arguments of a `subscription` - * operation. Used by the experimental WS subscription layer - * (cwist/net/graphql/graphql_ws.h); not part of the query/mutation execute - * path. Only the first root field is reported, matching the single-stream - * model of the graphql-ws broker. */ +/** + * @brief Extract the root field name and parsed arguments of a `subscription` + * operation. + * @details Used by the experimental WS subscription layer + * (cwist/net/graphql/graphql_ws.h); not part of the query/mutation + * execute path. Only the first root field is reported, matching the + * single-stream model of the graphql-ws broker. + * @param query GraphQL subscription query text. + * @param variables Optional cJSON object of variable values. + * @param field_out Buffer receiving the resolved root field name. + * @param field_cap Capacity of @p field_out in bytes. + * @param args_out Receives a newly allocated cJSON argument object (NULL when + * the field carries no arguments); caller owns it. + * @retval true The subscription field and arguments were extracted. + * @retval false Invalid arguments or malformed subscription query. + */ bool cwist_graphql_parse_subscription(const char *query, const cJSON *variables, char *field_out, size_t field_cap, cJSON **args_out) { if (!query || !field_out || field_cap == 0 || !args_out) return false; @@ -478,6 +605,16 @@ bool cwist_graphql_parse_subscription(const char *query, const cJSON *variables, return true; } +/** + * @brief HTTP handler serving GraphQL requests over POST. + * @details Executes the request body via cwist_graphql_execute() and writes + * the JSON response. Non-POST requests or execution failures yield a + * 400 status with an error body. The Content-Type header is always + * set to application/json. + * @param schema Schema used to resolve the request. + * @param req Incoming HTTP request (must use POST). + * @param res Response object receiving status, body, and headers. + */ void cwist_graphql_serve(cwist_graphql_schema_t *schema, cwist_http_request *req, cwist_http_response *res) { if (!schema || !req || !res || req->method != CWIST_HTTP_POST) { diff --git a/src/net/graphql/graphql_ws.c b/src/net/graphql/graphql_ws.c index 68fb24f35..56c8b1c4b 100644 --- a/src/net/graphql/graphql_ws.c +++ b/src/net/graphql/graphql_ws.c @@ -52,10 +52,27 @@ struct cwist_graphql_event_source { size_t topic_cap; }; +/** + * @brief Create an empty event source (topic interest set). + * @return New event source, or NULL on allocation failure. Owned by the + * caller; free with cwist_graphql_event_source_destroy(). + */ cwist_graphql_event_source_t *cwist_graphql_event_source_create(void) { return (cwist_graphql_event_source_t *)cwist_alloc(sizeof(cwist_graphql_event_source_t)); } +/** + * @brief Add a topic to an event source. + * + * Idempotent: adding a topic that is already present succeeds without + * duplicating it. The topic string is copied, so the caller keeps ownership + * of @p topic. + * + * @param src Event source to modify; must not be NULL. + * @param topic Topic name to add; must be a non-empty string. + * @return true on success (including the already-present case), false on + * invalid arguments or allocation failure. + */ bool cwist_graphql_event_source_add_topic(cwist_graphql_event_source_t *src, const char *topic) { if (!src || !topic || topic[0] == '\0') return false; for (size_t i = 0; i < src->topic_count; i++) { @@ -76,6 +93,11 @@ bool cwist_graphql_event_source_add_topic(cwist_graphql_event_source_t *src, con return true; } +/** + * @brief Destroy an event source, freeing all topic strings and the set. + * + * @param src Event source to free; NULL is a no-op. + */ void cwist_graphql_event_source_destroy(cwist_graphql_event_source_t *src) { if (!src) return; for (size_t i = 0; i < src->topic_count; i++) cwist_free(src->topics[i]); @@ -83,6 +105,12 @@ void cwist_graphql_event_source_destroy(cwist_graphql_event_source_t *src) { cwist_free(src); } +/** + * @brief Test whether an event source is interested in @p topic. + * @param src Event source; must not be NULL. + * @param topic Topic name to look up. + * @return true if @p topic is in the set, false otherwise. + */ static bool gql_src_has_topic(const cwist_graphql_event_source_t *src, const char *topic) { for (size_t i = 0; i < src->topic_count; i++) { if (strcmp(src->topics[i], topic) == 0) return true; @@ -106,6 +134,13 @@ struct cwist_graphql_ws { gql_sub_field_t *subs; }; +/** + * @brief Create a graphql-ws endpoint holding a subscription field registry. + * @param schema GraphQL schema the endpoint answers to; not owned, must + * outlive the endpoint. + * @return New endpoint, or NULL if @p schema is NULL or allocation fails. + * Free with cwist_graphql_ws_destroy(). + */ cwist_graphql_ws_t *cwist_graphql_ws_create(cwist_graphql_schema_t *schema) { if (!schema) return NULL; cwist_graphql_ws_t *gws = (cwist_graphql_ws_t *)cwist_alloc(sizeof(*gws)); @@ -116,6 +151,14 @@ cwist_graphql_ws_t *cwist_graphql_ws_create(cwist_graphql_schema_t *schema) { return gws; } +/** + * @brief Destroy an endpoint and its subscription field registry. + * + * Does not touch active connections or broker operations still referencing + * the endpoint's resolvers. + * + * @param gws Endpoint to free; NULL is a no-op. + */ void cwist_graphql_ws_destroy(cwist_graphql_ws_t *gws) { if (!gws) return; gql_sub_field_t *f = gws->subs; @@ -128,6 +171,22 @@ void cwist_graphql_ws_destroy(cwist_graphql_ws_t *gws) { cwist_free(gws); } +/** + * @brief Register (or replace) the resolver for a subscription root field. + * + * If the field is already registered, its resolver and context are replaced + * in place; otherwise a new entry is prepended to the registry. The field + * name is copied. + * + * @param gws Endpoint to register on; must not be NULL. + * @param field Subscription root field name; must be a non-empty string. + * @param subscribe_fn Resolver invoked per `subscribe` for @p field; must not + * be NULL. It receives the parsed arguments and variables and returns + * an event source (owned by the broker) or NULL to reject the + * operation. + * @param ctx Opaque pointer passed through to @p subscribe_fn. + * @return true on success, false on invalid arguments or allocation failure. + */ bool cwist_graphql_ws_add_subscription(cwist_graphql_ws_t *gws, const char *field, cwist_graphql_subscribe_fn subscribe_fn, void *ctx) { if (!gws || !field || field[0] == '\0' || !subscribe_fn) return false; @@ -190,23 +249,37 @@ typedef struct gql_ws_flush { static pthread_mutex_t g_broker_lock = PTHREAD_MUTEX_INITIALIZER; static gql_ws_op_t *g_broker_ops; /* all active operations, all endpoints */ +/** + * @brief Take a connection reference (atomic, thread-safe). + * @param conn Connection to ref. + */ static void gql_ws_conn_ref(gql_ws_conn_t *conn) { atomic_fetch_add_explicit(&conn->refs, 1, memory_order_relaxed); } -/* Frees the connection state once the WS layer, the protocol layer, and all - * in-flight flush nodes have released their references. */ +/** + * @brief Frees the connection state once the WS layer, the protocol layer, and all + * in-flight flush nodes have released their references. + */ static void gql_ws_conn_free(gql_ws_conn_t *conn) { for (size_t i = 0; i < conn->pending_count; i++) cwist_free(conn->pending[i]); cwist_free(conn->pending); cwist_free(conn); } +/** + * @brief Drop a connection reference; frees the connection at zero. + * + * Thread-safe (atomic). The last unref runs gql_ws_conn_free(). + */ static void gql_ws_conn_unref(gql_ws_conn_t *conn) { if (atomic_fetch_sub_explicit(&conn->refs, 1, memory_order_acq_rel) == 1) gql_ws_conn_free(conn); } +/** + * @brief Free a broker operation and everything it owns (id, field, event source). + */ static void gql_ws_op_free(gql_ws_op_t *op) { cwist_free(op->id); cwist_free(op->field); @@ -214,14 +287,23 @@ static void gql_ws_op_free(gql_ws_op_t *op) { cwist_free(op); } -/* Remove @p victim from the global broker list (caller holds g_broker_lock). */ +/** + * @brief Remove @p victim from the global broker list. + * + * @param victim Operation to unlink. Caller must hold g_broker_lock. + */ static void gql_ws_op_unlink(gql_ws_op_t *victim) { gql_ws_op_t **pp = &g_broker_ops; while (*pp && *pp != victim) pp = &(*pp)->next; if (*pp) *pp = victim->next; } -/* Unlink and free every operation of @p conn (caller holds g_broker_lock). */ +/** + * @brief Unlink and free every operation of @p conn. + * + * @param conn Connection whose operations are purged. Caller must hold + * g_broker_lock. + */ static void gql_ws_conn_purge_ops(gql_ws_conn_t *conn) { gql_ws_op_t *op = g_broker_ops; while (op) { @@ -238,6 +320,15 @@ static void gql_ws_conn_purge_ops(gql_ws_conn_t *conn) { /* Wire helpers (reactor thread only) */ /* ------------------------------------------------------------------ */ +/** + * @brief Serialize and send a JSON message, then destroy it. + * + * Takes ownership of @p msg regardless of outcome. Closes the socket on + * print or send failure. + * + * @param ws Socket to send on; reactor thread only. + * @param msg cJSON message; consumed by this call. + */ static void gql_ws_send_json(cwist_websocket_async *ws, cJSON *msg) { char *printed = cJSON_PrintUnformatted(msg); cJSON_Delete(msg); @@ -251,6 +342,15 @@ static void gql_ws_send_json(cwist_websocket_async *ws, cJSON *msg) { cwist_free(printed); } +/** + * @brief Send a graphql-ws `error` message {type,id,payload:[{message}]}. + * + * Closes the socket if the message cannot be built. + * + * @param ws Socket to send on; reactor thread only. + * @param id Operation id, or NULL for connection-level errors (sent as ""). + * @param message Human-readable error text. + */ static void gql_ws_send_error(cwist_websocket_async *ws, const char *id, const char *message) { cJSON *err = cJSON_CreateObject(); cJSON *payload = cJSON_CreateArray(); diff --git a/src/net/grpc/grpc.c b/src/net/grpc/grpc.c index d697ec334..413bcfd98 100644 --- a/src/net/grpc/grpc.c +++ b/src/net/grpc/grpc.c @@ -84,11 +84,21 @@ struct cwist_grpc_session { static void grpc_session_send_trailers(cwist_grpc_session *session, cwist_grpc_status_t status, const char *message); +/** @brief Check whether a Content-Type header value selects gRPC. + * + * @param content_type Header value; may be NULL. + * @retval 1 Value starts with "application/grpc". + * @retval 0 NULL value or a non-gRPC content type. */ static int grpc_content_type_is_grpc(const char *content_type) { if (!content_type) return 0; return strncmp(content_type, "application/grpc", 16) == 0; } +/** @brief Look up a registered gRPC route by request path. + * + * @param app App owning the route list; may be NULL. + * @param path Request path to match; may be NULL. + * @return Matching route, or NULL when no route matches. */ static cwist_grpc_route *grpc_find_route(cwist_app *app, const char *path) { if (!app || !path) return NULL; cwist_grpc_route *route = (cwist_grpc_route *)app->grpc_routes; @@ -99,12 +109,20 @@ static cwist_grpc_route *grpc_find_route(cwist_app *app, const char *path) { return NULL; } +/** @brief Current monotonic clock in milliseconds. + * + * @return Milliseconds elapsed since an unspecified epoch (CLOCK_MONOTONIC). */ static uint64_t grpc_now_ms(void) { struct timespec ts; clock_gettime(CLOCK_MONOTONIC, &ts); return (uint64_t)ts.tv_sec * 1000 + (uint64_t)ts.tv_nsec / 1000000; } +/** @brief Case-insensitively fetch a request header value. + * + * @param req Request to search; may be NULL. + * @param key Header name to match; may be NULL. + * @return Header value, or NULL when absent or on NULL input. */ static const char *grpc_header_get(cwist_http_request *req, const char *key) { if (!req || !key) return NULL; for (cwist_http_header_node *h = req->headers; h; h = h->next) { @@ -115,6 +133,16 @@ static const char *grpc_header_get(cwist_http_request *req, const char *key) { return NULL; } +/** @brief Parse a gRPC timeout value into milliseconds. + * + * Accepts the gRPC "TimeoutValue Unit" form with units H, M, S, m, u, n + * (hours down to nanoseconds); sub-millisecond values round up so a positive + * timeout never becomes zero. + * + * @param value Timeout string, e.g. "10S" or "500m"; may be NULL. + * @param out_ms Receives the timeout in milliseconds. + * @retval 0 Parsed successfully. + * @retval -1 NULL input, malformed value, or unsupported unit. */ int cwist_grpc_parse_timeout(const char *value, uint64_t *out_ms) { if (!value || !out_ms) return -1; size_t len = strlen(value); @@ -148,8 +176,11 @@ int cwist_grpc_parse_timeout(const char *value, uint64_t *out_ms) { return 0; } -/* grpc-encoding the request declared: 0 = identity/absent, 1 = gzip, - * -1 = unsupported (must be rejected with UNIMPLEMENTED). */ +/** @brief Determine the grpc-encoding declared by the request. + * + * @param req Request to inspect. + * @return 0 for identity/absent, 1 for gzip, -1 for unsupported encodings + * (which must be rejected with UNIMPLEMENTED). */ static int grpc_request_encoding(cwist_http_request *req) { const char *enc = grpc_header_get(req, "grpc-encoding"); if (!enc || strcmp(enc, "identity") == 0) return 0; @@ -157,6 +188,13 @@ static int grpc_request_encoding(cwist_http_request *req) { return -1; } +/** @brief Check whether the client advertised gzip in grpc-accept-encoding. + * + * Tolerates whitespace and multiple comma-separated tokens. + * + * @param req Request to inspect. + * @retval 1 gzip is accepted by the client. + * @retval 0 Header absent or gzip not listed. */ static int grpc_client_accepts_gzip(cwist_http_request *req) { const char *ae = grpc_header_get(req, "grpc-accept-encoding"); if (!ae) return 0; @@ -171,6 +209,14 @@ static int grpc_client_accepts_gzip(cwist_http_request *req) { return 0; } +/** @brief Gzip-compress a buffer with zlib. + * + * @param in Input bytes; may be NULL when @p in_len is 0. + * @param in_len Input length in bytes. + * @param out Receives a newly allocated buffer (caller frees with cwist_free()). + * @param out_len Receives the compressed length in bytes. + * @retval 0 Compression succeeded. + * @retval -1 zlib initialization or compression failure. */ static int grpc_gzip_deflate(const uint8_t *in, size_t in_len, uint8_t **out, size_t *out_len) { z_stream zs; memset(&zs, 0, sizeof(zs)); @@ -200,6 +246,16 @@ static int grpc_gzip_deflate(const uint8_t *in, size_t in_len, uint8_t **out, si return 0; } +/** @brief Decompress a gzip buffer with zlib. + * + * The output buffer grows geometrically as needed. + * + * @param in Compressed input bytes. + * @param in_len Compressed length in bytes. + * @param out Receives a newly allocated buffer (caller frees with cwist_free()). + * @param out_len Receives the decompressed length in bytes. + * @retval 0 Decompression succeeded. + * @retval -1 zlib failure, corrupt input, or allocation failure. */ static int grpc_gzip_inflate(const uint8_t *in, size_t in_len, uint8_t **out, size_t *out_len) { z_stream zs; memset(&zs, 0, sizeof(zs)); @@ -241,7 +297,8 @@ static int grpc_gzip_inflate(const uint8_t *in, size_t in_len, uint8_t **out, si return 0; } -/* Decompress a compressed-flag message according to grpc-encoding. +/** @brief Decompress a compressed-flag message according to grpc-encoding. + * * On success the message points at an owned buffer returned in @p owned * (caller frees). Returns 0 on success, -1 when the encoding is * unsupported or the payload is corrupt. */ @@ -260,6 +317,10 @@ static int grpc_message_inflate(cwist_http_request *req, cwist_grpc_message *mes return 0; } +/** @brief Map a base64 character to its 6-bit value. + * + * @param c Character to decode. + * @return Value 0-63, or -1 when the character is not in the base64 alphabet. */ static int grpc_b64_val(int c) { if (c >= 'A' && c <= 'Z') return c - 'A'; if (c >= 'a' && c <= 'z') return c - 'a' + 26; @@ -269,6 +330,18 @@ static int grpc_b64_val(int c) { return -1; } +/** @brief Decode a base64 string (with optional '=' padding) into bytes. + * + * Decoding stops at the first '=' padding character. + * + * @param in Base64 input text. + * @param in_len Input length in bytes. + * @param out Output buffer. + * @param out_cap Capacity of @p out in bytes. + * @param out_len Receives the decoded length in bytes. + * @retval 0 Decoding succeeded. + * @retval -1 Invalid character encountered. + * @retval -2 Output buffer too small. */ static int grpc_base64_decode(const char *in, size_t in_len, uint8_t *out, size_t out_cap, size_t *out_len) { uint32_t acc = 0; @@ -290,10 +363,25 @@ static int grpc_base64_decode(const char *in, size_t in_len, uint8_t *out, size_ return 0; } +/** @brief Fetch a metadata header value from a gRPC request. + * + * @param req Request to search; may be NULL. + * @param key Header name to match; may be NULL. + * @return Header value, or NULL when absent or on NULL input. */ const char *cwist_grpc_metadata_get(cwist_http_request *req, const char *key) { return grpc_header_get(req, key); } +/** @brief Decode a binary ("-bin" suffixed) metadata header from base64. + * + * @param req Request to search; may be NULL. + * @param key Metadata key; must end in "-bin". + * @param out Output buffer for the decoded bytes. + * @param out_cap Capacity of @p out in bytes. + * @param out_len Receives the decoded length in bytes. + * @retval 0 Decoding succeeded. + * @retval -1 NULL input, key without "-bin" suffix, missing header, or + * decode failure (see grpc_base64_decode for -2 overflow). */ int cwist_grpc_metadata_get_binary(cwist_http_request *req, const char *key, uint8_t *out, size_t out_cap, size_t *out_len) { if (!req || !key || !out_len) return -1; @@ -304,6 +392,16 @@ int cwist_grpc_metadata_get_binary(cwist_http_request *req, const char *key, uin return grpc_base64_decode(value, strlen(value), out, out_cap, out_len); } +/** @brief Dispatch a unary gRPC request to its registered handler. + * + * Validates content-type, body, and grpc-encoding, decodes (and when needed + * inflates) the request message, invokes the route handler, then opportunistically + * gzip-compresses the response body when the client advertised + * grpc-accept-encoding: gzip. All protocol errors are written into @p res + * as gRPC error responses. + * + * @param req Incoming unary request. + * @param res Response object the handler fills in. */ static void grpc_dispatch_unary(cwist_http_request *req, cwist_http_response *res) { if (!req || !res || !req->app || !req->path || !req->path->data) return; @@ -367,6 +465,13 @@ static void grpc_dispatch_unary(cwist_http_request *req, cwist_http_response *re } } +/** @brief Validate the gRPC framing of a request before buffered dispatch. + * + * On failure a matching gRPC error (INVALID_ARGUMENT or UNIMPLEMENTED) is + * written into @p res. + * + * @retval 0 Request is well-formed. + * @retval -1 Validation failed; @p res carries the error response. */ static int grpc_validate_request(cwist_http_request *req, cwist_http_response *res) { const char *ct = cwist_http_header_get(req->headers, "content-type"); if (!grpc_content_type_is_grpc(ct)) { @@ -388,6 +493,17 @@ static int grpc_validate_request(cwist_http_request *req, cwist_http_response *r return 0; } +/** @brief Dispatch a buffered client-streaming gRPC request to its handler. + * + * Decodes every message in the request body (inflating compressed ones when + * grpc-encoding: gzip), computes the grpc-timeout deadline, then invokes the + * streaming handler with a pre-populated cwist_grpc_stream and closes the + * stream with the handler's final status. All protocol and allocation + * failures are written into @p res as gRPC error responses. + * + * @param req Incoming request whose body holds concatenated length-prefixed + * messages. + * @param res Response object filled from the handler's stream state. */ static void grpc_dispatch_stream(cwist_http_request *req, cwist_http_response *res) { if (!req || !res || !req->app || !req->path || !req->path->data) return; @@ -494,6 +610,15 @@ static void grpc_dispatch_stream(cwist_http_request *req, cwist_http_response *r cwist_free(messages); } +/** @brief Decode a single gRPC length-prefixed message frame. + * + * @param frame Frame bytes: 1-byte compression flag + 4-byte big-endian + * length + payload. + * @param frame_len Total frame length; must be exactly 5 + payload length. + * @param out Receives compression flag and a pointer/length into @p frame + * (borrowed; not copied). + * @retval 0 Success. + * @retval -1 NULL input, frame shorter than 5 bytes, or length mismatch. */ int cwist_grpc_decode_message(const void *frame, size_t frame_len, cwist_grpc_message *out) { if (!frame || !out || frame_len < 5) return -1; const uint8_t *p = (const uint8_t *)frame; @@ -508,6 +633,19 @@ int cwist_grpc_decode_message(const void *frame, size_t frame_len, cwist_grpc_me return 0; } +/** @brief Decode the next message in a concatenated gRPC frame buffer. + * + * Advances *@p offset past the decoded message so it can be called again to + * iterate. + * + * @param frames Buffer holding concatenated length-prefixed messages. + * @param frames_len Total buffer length. + * @param offset In/out cursor into @p frames; on entry the message start, on + * exit one past the decoded message. + * @param out Receives the compression flag and a borrowed pointer/length + * into @p frames. + * @retval 0 Success. + * @retval -1 NULL input, offset out of range, or truncated frame. */ int cwist_grpc_decode_next_message(const void *frames, size_t frames_len, size_t *offset, cwist_grpc_message *out) { if (!frames || !offset || !out || *offset > frames_len) return -1; @@ -526,6 +664,16 @@ int cwist_grpc_decode_next_message(const void *frames, size_t frames_len, size_t return 0; } +/** @brief Wrap a payload in a gRPC length-prefixed message frame. + * + * @param payload Message bytes; may be NULL when @p payload_len is 0. + * @param payload_len Payload length; must fit in a 32-bit field. + * @param compressed Non-zero to set the compression flag in the frame header. + * @param out Receives a newly allocated frame. + * @param out_len Receives the frame length (5 + payload length). + * @retval 0 Success; caller owns *@p out and must free it with cwist_free(). + * @retval -1 NULL output pointers, NULL payload with nonzero length, payload + * too large, or allocation failure. */ int cwist_grpc_encode_message(const void *payload, size_t payload_len, uint8_t compressed, uint8_t **out, size_t *out_len) { if (!out || !out_len || (payload_len > 0 && !payload)) return -1; @@ -545,18 +693,44 @@ int cwist_grpc_encode_message(const void *payload, size_t payload_len, uint8_t c return 0; } +/** @brief Initialize an incremental gRPC message decoder. + * + * @param decoder Decoder state to zero and configure. + * @param max_message_size Maximum accepted payload in bytes; 0 selects the + * default of 16 MiB. */ void cwist_grpc_decoder_init(cwist_grpc_decoder *decoder, size_t max_message_size) { if (!decoder) return; memset(decoder, 0, sizeof(*decoder)); decoder->max_message_size = max_message_size ? max_message_size : (16u * 1024u * 1024u); } +/** @brief Release all resources held by a decoder and reset it to zero. + * + * Safe to call on a decoder that was only ever initialized or already + * destroyed. Afterwards the decoder must not be used without re-init. + * + * @param decoder Decoder to destroy; may be NULL. */ void cwist_grpc_decoder_destroy(cwist_grpc_decoder *decoder) { if (!decoder) return; cwist_free(decoder->payload); memset(decoder, 0, sizeof(*decoder)); } +/** @brief Feed bytes into an incremental gRPC message decoder. + * + * Buffers partial frames internally and invokes @p callback with each fully + * decoded message. The message payload pointer passed to the callback is + * owned by the decoder and is only valid for the duration of the call. + * + * @param decoder Decoder state. + * @param data Incoming bytes; may be NULL only when @p len is 0. + * @param len Number of incoming bytes. + * @param callback Invoked per completed message; a nonzero return aborts. + * @param ctx Opaque pointer passed through to @p callback. + * @retval 0 All input consumed. + * @retval -1 Invalid argument, frame too large or malformed, allocation + * failure, or the callback returned nonzero (decoder state is then + * unusable). */ int cwist_grpc_decoder_feed(cwist_grpc_decoder *decoder, const void *data, size_t len, cwist_grpc_message_callback callback, void *ctx) { if (!decoder || (!data && len) || !callback) return -1; @@ -597,6 +771,16 @@ int cwist_grpc_decoder_feed(cwist_grpc_decoder *decoder, const void *data, size_ return 0; } +/** @brief Build a complete buffered unary gRPC response. + * + * Sets the HTTP 200 status, gRPC content-type headers, grpc-status/grpc-message + * headers, and a single length-prefixed message frame from @p payload. + * + * @param res Response object to fill; may be NULL (no-op). + * @param status gRPC status code for the grpc-status header. + * @param message Optional status message for the grpc-message header. + * @param payload Message payload bytes; may be NULL for an empty message. + * @param payload_len Payload length in bytes. */ void cwist_grpc_set_response(cwist_http_response *res, cwist_grpc_status_t status, const char *message, const void *payload, size_t payload_len) { if (!res) return; @@ -625,11 +809,30 @@ void cwist_grpc_set_response(cwist_http_response *res, cwist_grpc_status_t statu } } +/** @brief Build a buffered gRPC error response with an empty message. + * + * Convenience wrapper over cwist_grpc_set_response() with no payload. + * + * @param res Response object to fill. + * @param status gRPC status code. + * @param message Status message for the grpc-message header; may be NULL. */ void cwist_grpc_set_error(cwist_http_response *res, cwist_grpc_status_t status, const char *message) { cwist_grpc_set_response(res, status, message, NULL, 0); } +/** @brief Send one message on an open gRPC stream. + * + * Frames the payload (gzip-compressing it when the peer negotiated gzip and + * deflation succeeds) and delivers it either to the transport-backed + * write_frame hook or by appending to the buffered response body. On any + * failure the stream status and status message are set to describe the error. + * + * @param stream Open stream to send on; may be NULL. + * @param payload Message bytes; may be NULL when @p payload_len is 0. + * @param payload_len Message length in bytes. + * @retval 0 Message accepted by the transport/buffer. + * @retval -1 Encoding or write failure; stream->status is updated. */ int cwist_grpc_stream_send(cwist_grpc_stream *stream, const void *payload, size_t payload_len) { if (!stream) return -1; int use_gzip = 0; @@ -701,6 +904,15 @@ int cwist_grpc_stream_send(cwist_grpc_stream *stream, const void *payload, size_ return 0; } +/** @brief Install a custom frame writer on a stream. + * + * Overrides where cwist_grpc_stream_send() delivers frames (and, for the + * buffered path, cwist_grpc_stream_close() trailers are still used). May be + * NULL to clear the hook. + * + * @param stream Stream to configure; may be NULL (no-op). + * @param write_frame Callback invoked with each framed message; may be NULL. + * @param ctx Opaque pointer passed as the first callback argument. */ void cwist_grpc_stream_set_writer(cwist_grpc_stream *stream, int (*write_frame)(void *, const uint8_t *, size_t, int), void *ctx) { @@ -709,6 +921,15 @@ void cwist_grpc_stream_set_writer(cwist_grpc_stream *stream, stream->write_frame_ctx = ctx; } +/** @brief Close a gRPC stream with a final status. + * + * Idempotent: a second call is a no-op. Emits grpc-status/grpc-message + * trailers through the transport session when present, otherwise adds them + * as response headers on the buffered path. + * + * @param stream Stream to close; may be NULL (no-op). + * @param status Final gRPC status. + * @param message Final status message; may be NULL. */ void cwist_grpc_stream_close(cwist_grpc_stream *stream, cwist_grpc_status_t status, const char *message) { if (!stream) return; @@ -731,6 +952,14 @@ void cwist_grpc_stream_close(cwist_grpc_stream *stream, cwist_grpc_status_t stat } } +/** @brief Advertise a server-side retry pushback hint to the client. + * + * Records the hint on the stream; on the buffered path it is also emitted as + * the grpc-retry-pushback-ms header for the HTTP/2 layer to move into the + * trailers. + * + * @param stream Stream to annotate; may be NULL (no-op). + * @param ms Retry delay hint in milliseconds. */ void cwist_grpc_stream_set_retry_pushback(cwist_grpc_stream *stream, int32_t ms) { if (!stream) return; stream->retry_pushback_ms = ms; @@ -744,7 +973,14 @@ void cwist_grpc_stream_set_retry_pushback(cwist_grpc_stream *stream, int32_t ms) } } -/* Caller must hold reg->mu. Empty/NULL service names the whole server. */ +/** @brief Look up the ServingStatus for a service in the health registry. + * + * Caller must hold reg->mu. Empty/NULL service names the whole server. + * + * @retval 1 SERVING. + * @retval 2 NOT_SERVING. + * @retval 3 SERVICE_UNKNOWN (only when a concrete service name is given and + * no status was ever recorded for it). */ static int grpc_health_status_locked(const cwist_grpc_health_registry *reg, const char *service) { for (const cwist_grpc_health_state *it = reg->states; it; it = it->next) if (it->service && strcmp(it->service, service ? service : "") == 0) @@ -752,7 +988,11 @@ static int grpc_health_status_locked(const cwist_grpc_health_registry *reg, cons return service && *service ? 3 : 1; /* SERVICE_UNKNOWN, or overall SERVING */ } -/* Extract the HealthCheckRequest service field as an owned string. */ +/** @brief Extract the HealthCheckRequest service field as an owned string. + * + * @param message Decoded HealthCheckRequest protobuf message. + * @return Newly allocated NUL-terminated copy of field 1, or NULL when the + * field is absent or allocation fails. Caller frees with cwist_free(). */ static char *grpc_health_service_copy(const cwist_grpc_message *message) { cwist_pb_reader reader; cwist_pb_reader_init(&reader, message->data, message->len); @@ -768,6 +1008,12 @@ static char *grpc_health_service_copy(const cwist_grpc_message *message) { return NULL; } +/** @brief Resolve and encode a HealthCheckResponse for a Check request. + * + * @param res Response to fill. + * @param reg Shared health registry. + * @param message Decoded HealthCheckRequest; the service field selects the + * entry to report on. */ static void grpc_health_reply(cwist_http_response *res, cwist_grpc_health_registry *reg, const cwist_grpc_message *message) { char *service = grpc_health_service_copy(message); @@ -784,12 +1030,28 @@ static void grpc_health_reply(cwist_http_response *res, cwist_grpc_health_regist cwist_free(service); } +/** @brief Unary handler for grpc.health.v1 Health/Check. + * + * Thin adapter over grpc_health_reply(); @p ctx is the shared health registry. + * + * @param req Unused. + * @param res Response to fill. + * @param message Decoded HealthCheckRequest. + * @param ctx cwist_grpc_health_registry route user context. */ static void grpc_health_check(cwist_http_request *req, cwist_http_response *res, const cwist_grpc_message *message, void *ctx) { (void)req; grpc_health_reply(res, ctx, message); } +/** @brief Encode and send the current health status as a stream message. + * + * Resolves the status under the registry lock, writes a HealthCheckResponse, + * and sends it on @p stream. On failure the stream status is set to INTERNAL + * with a "health encoding failed" message. + * + * @retval 0 Status message sent. + * @retval -1 Encoding or send failure; stream->status updated. */ static int grpc_health_send_status(cwist_grpc_stream *stream, cwist_grpc_health_registry *reg, const char *service) { pthread_mutex_lock(®->mu); @@ -807,6 +1069,14 @@ static int grpc_health_send_status(cwist_grpc_stream *stream, cwist_grpc_health_ return rc; } +/** @brief Streaming handler for grpc.health.v1 Health/Watch. + * + * Sends the current status immediately, then on the transport path waits for + * registry generation changes and re-sends the status on each change, until + * the stream is cancelled or its deadline expires. The buffered path + * delivers a single snapshot. + * + * @param stream Open Watch stream; @p ctx is the shared health registry. */ static void grpc_health_watch(cwist_grpc_stream *stream, void *ctx) { cwist_grpc_health_registry *reg = ctx; char *service = NULL; @@ -855,6 +1125,15 @@ static void grpc_health_watch(cwist_grpc_stream *stream, void *ctx) { cwist_free(service); } +/** @brief Register the built-in grpc.health.v1.Health service on an app. + * + * Installs the Check (unary) and Watch (streaming) routes backed by a single + * newly allocated health registry. On failure the registry is freed and no + * routes remain half-installed. + * + * @param app Application to register on; may be NULL. + * @retval 0 Both routes registered. + * @retval -1 NULL app, allocation failure, or route registration failure. */ int cwist_app_grpc_health(cwist_app *app) { if (!app) return -1; cwist_grpc_health_registry *reg = cwist_alloc(sizeof(*reg)); @@ -879,6 +1158,18 @@ int cwist_app_grpc_health(cwist_app *app) { return 0; } +/** @brief Set or update the serving status of a named service. + * + * Records the status in the health registry and wakes all active Watch + * streams. Thread-safe; takes the registry lock internally. + * + * @param app App whose health service was registered with + * cwist_app_grpc_health(). + * @param service Service name to update. + * @param serving Non-zero for SERVING, zero for NOT_SERVING. + * @retval 0 Status recorded. + * @retval -1 No health service registered, NULL arguments, or allocation + * failure. */ int cwist_app_grpc_health_set_status(cwist_app *app, const char *service, int serving) { cwist_grpc_route *route = grpc_find_route(app, "/grpc.health.v1.Health/Check"); if (!route || !route->user_ctx || !service) return -1; @@ -913,6 +1204,11 @@ int cwist_app_grpc_health_set_status(cwist_app *app, const char *service, int se return 0; } +/** @brief Count currently active Health/Watch streams. + * + * @param app App whose health service was registered. + * @return Number of active watchers (atomic snapshot). + * @retval -1 No health Watch route registered on @p app. */ int cwist_app_grpc_health_watchers(cwist_app *app) { cwist_grpc_route *route = grpc_find_route(app, "/grpc.health.v1.Health/Watch"); if (!route || !route->user_ctx) return -1; @@ -920,6 +1216,13 @@ int cwist_app_grpc_health_watchers(cwist_app *app) { return atomic_load(®->watchers); } +/** @brief Append one service entry to a ServerReflectionResponse writer. + * + * Builds the nested ServiceResponse/ServiceResponse.name structure (field 6) + * for @p service. + * + * @retval 0 Entry appended. + * @retval -1 Protobuf writer failure. */ static int grpc_reflection_append_service(cwist_pb_writer *response, const char *service) { cwist_pb_writer item, list; cwist_pb_writer_init(&item); @@ -932,6 +1235,15 @@ static int grpc_reflection_append_service(cwist_pb_writer *response, const char return rc ? -1 : 0; } +/** @brief Streaming handler for ServerReflectionInfo (list_services). + * + * Walks the app's registered gRPC routes, derives each service name from its + * "/service/method" path, and streams a single ServerReflectionResponse + * listing them. On allocation/encoding failure the stream status is set + * accordingly and nothing further is sent. + * + * @param stream Open reflection stream. + * @param ctx Unused. */ static void grpc_reflection_info(cwist_grpc_stream *stream, void *ctx) { (void)ctx; cwist_pb_writer response; @@ -959,6 +1271,13 @@ static void grpc_reflection_info(cwist_grpc_stream *stream, void *ctx) { cwist_pb_writer_free(&response); } +/** @brief Register the built-in gRPC server reflection service on an app. + * + * Installs the streaming ServerReflectionInfo route. + * + * @param app Application to register on. + * @retval 0 Route registered. + * @retval -1 Route registration failed. */ int cwist_app_grpc_reflection(cwist_app *app) { if (grpc_register_route(app, "grpc.reflection.v1alpha.ServerReflection", "ServerReflectionInfo", 1, NULL, grpc_reflection_info, NULL) != 0) @@ -969,6 +1288,22 @@ int cwist_app_grpc_reflection(cwist_app *app) { return 0; } +/** @brief Register a gRPC route and its HTTP POST dispatch handler. + * + * Builds the "/service/method" path, replaces an existing route with the same + * path in place, or prepends a new route and posts it to the app's route + * table. Route memory is owned by the app and released by + * cwist_grpc_routes_destroy(). + * + * @param app Application to register on. + * @param service gRPC service name, e.g. "grpc.health.v1.Health". + * @param method gRPC method name. + * @param streaming Non-zero for a streaming (client-streaming) handler. + * @param unary_handler Handler for unary routes; NULL for streaming routes. + * @param stream_handler Handler for streaming routes; NULL for unary routes. + * @param user_ctx Opaque pointer passed to the handler. + * @retval 0 Route registered or replaced. + * @retval -1 NULL/empty arguments or allocation failure. */ static int grpc_register_route(cwist_app *app, const char *service, const char *method, int streaming, cwist_grpc_unary_handler_func unary_handler, cwist_grpc_stream_handler_func stream_handler, void *user_ctx) { @@ -1010,16 +1345,40 @@ static int grpc_register_route(cwist_app *app, const char *service, const char * return 0; } +/** @brief Register a unary gRPC method on an app. + * + * @param app Application to register on. + * @param service gRPC service name. + * @param method Method name. + * @param handler Unary handler invoked per request. + * @param user_ctx Opaque pointer passed to @p handler. + * @retval 0 Registered. + * @retval -1 See grpc_register_route(). */ int cwist_app_grpc_unary(cwist_app *app, const char *service, const char *method, cwist_grpc_unary_handler_func handler, void *user_ctx) { return grpc_register_route(app, service, method, 0, handler, NULL, user_ctx); } +/** @brief Register a client-streaming gRPC method on an app. + * + * @param app Application to register on. + * @param service gRPC service name. + * @param method Method name. + * @param handler Streaming handler invoked once per call with the stream. + * @param user_ctx Opaque pointer passed to @p handler. + * @retval 0 Registered. + * @retval -1 See grpc_register_route(). */ int cwist_app_grpc_stream(cwist_app *app, const char *service, const char *method, cwist_grpc_stream_handler_func handler, void *user_ctx) { return grpc_register_route(app, service, method, 1, NULL, handler, user_ctx); } +/** @brief Free all gRPC routes and per-route resources on an app. + * + * Also tears down the health registry owned by the built-in health Check + * route (states, mutex, condvar). Leaves app->grpc_routes NULL. + * + * @param app App to clean up; may be NULL (no-op). */ void cwist_grpc_routes_destroy(cwist_app *app) { if (!app) return; cwist_grpc_route *route = (cwist_grpc_route *)app->grpc_routes; @@ -1045,6 +1404,16 @@ void cwist_grpc_routes_destroy(cwist_app *app) { app->grpc_routes = NULL; } +/** @brief Copy all gRPC routes (and built-in service state) to another app. + * + * Re-registers every route from @p src on @p dst. The built-in health + * service is re-created on @p dst with its recorded statuses, and the + * reflection service is re-registered. Routes already present on @p dst + * with the same path are replaced by grpc_register_route(). + * + * @retval 0 All routes cloned. + * @retval -1 NULL argument, allocation failure, malformed route path, or a + * registration failure part-way through (dst may hold partial state). */ int cwist_grpc_routes_clone(cwist_app *dst, const cwist_app *src) { if (!dst || !src) return -1; const cwist_grpc_route *route = (const cwist_grpc_route *)src->grpc_routes; @@ -1100,6 +1469,11 @@ typedef struct cwist_grpc_h2_conn_ctx { cwist_grpc_session *sessions; } cwist_grpc_h2_conn_ctx; +/** @brief Drop one reference on a connection context, freeing it at zero. + * + * Thread-safe; destroys the mutex and frees the context on the last release. + * + * @param ctx Context to release; must be non-NULL. */ static void grpc_conn_ctx_release(cwist_grpc_h2_conn_ctx *ctx) { int last; pthread_mutex_lock(&ctx->mu); @@ -1111,6 +1485,13 @@ static void grpc_conn_ctx_release(cwist_grpc_h2_conn_ctx *ctx) { } } +/** @brief Drop one reference on a streaming session, freeing it at zero. + * + * Removes the session from its connection's session list, releases the + * connection context, and frees the decoder, queued messages, receive buffer, + * embedded request, and locks. Thread-safe via the session's atomic refcount. + * + * @param session Session to release; must be non-NULL. */ static void grpc_session_release(cwist_grpc_session *session) { if (atomic_fetch_sub(&session->refs, 1) != 1) return; cwist_grpc_h2_conn_ctx *ctx = session->conn_ctx; @@ -1136,7 +1517,16 @@ static void grpc_session_release(cwist_grpc_session *session) { cwist_free(session); } -/* Queue a decoded inbound message for the handler thread. */ +/** @brief Queue a decoded inbound message for the handler thread. + * + * Takes session->mu. When the session is already cancelled or at EOF the + * data is freed instead of queued. + * + * @param session Session whose inbound queue receives the message. + * @param data Owned message buffer (freed here on failure). + * @param len Message length in bytes. + * @retval 0 Queued; the session now owns @p data. + * @retval -1 Session ended; @p data and the queue node were freed. */ static int grpc_session_push(cwist_grpc_session *session, uint8_t *data, size_t len) { grpc_qnode *node = (grpc_qnode *)cwist_alloc(sizeof(*node)); if (!node) return -1; @@ -1160,6 +1550,16 @@ static int grpc_session_push(cwist_grpc_session *session, uint8_t *data, size_t return 0; } +/** @brief Decoder callback: take ownership of a decoded message and queue it. + * + * Inflates compressed messages when the session negotiated gzip. The queued + * buffer is always freshly allocated and owned by the session queue. + * + * @param ctx cwist_grpc_session. + * @param message Decoded message (payload borrowed from the decoder). + * @retval 0 Message queued. + * @retval -1 Unsupported encoding, corrupt payload, allocation failure, or + * the session ended; the caller (decoder feed) then fails the call. */ static int grpc_session_decoded(void *ctx, const cwist_grpc_message *message) { cwist_grpc_session *session = ctx; uint8_t *copy; @@ -1177,7 +1577,15 @@ static int grpc_session_decoded(void *ctx, const cwist_grpc_message *message) { return 0; } -/* Fail the call: cancel the handler and emit error trailers. */ +/** @brief Fail the call: cancel the handler and emit error trailers. + * + * Marks the session and its public stream cancelled, wakes any blocked + * receiver, and sends trailers carrying @p status/@p message. Safe to call + * from the transport thread; takes session->mu and session->wmu. + * + * @param session Session to fail. + * @param status gRPC status for the trailers and stream state. + * @param message Status message; may be NULL. */ static void grpc_session_fail(cwist_grpc_session *session, cwist_grpc_status_t status, const char *message) { pthread_mutex_lock(&session->mu); @@ -1190,8 +1598,15 @@ static void grpc_session_fail(cwist_grpc_session *session, cwist_grpc_status_t s grpc_session_send_trailers(session, status, message); } -/* Send the delayed Response-Headers if they have not gone out yet. - * Caller holds session->wmu. */ +/** @brief Send the delayed Response-Headers if they have not gone out yet. + * + * Emits the content-type/grpc-accept-encoding (and grpc-encoding when the + * response is gzipped) HEADERS block with the 200 status. + * + * Caller holds session->wmu. + * + * @retval 0 Headers were already sent, or went out now. + * @retval -1 Transport detached (session->h2s NULL) or the write failed. */ static int grpc_session_send_headers_locked(cwist_grpc_session *session) { if (session->headers_sent) return 0; if (!session->h2s) return -1; @@ -1205,6 +1620,17 @@ static int grpc_session_send_headers_locked(cwist_grpc_session *session) { return 0; } +/** @brief Emit the terminal trailers (or a Trailers-Only HEADERS block). + * + * Idempotent per session. When the response headers never went out and the + * status is not OK, the whole response collapses into a single Trailers-Only + * HEADERS frame (gRFC A6) so conforming clients may retry. Otherwise the + * delayed Response-Headers are flushed first, followed by a trailers block + * with grpc-status/grpc-message (and grpc-retry-pushback-ms when set). + * + * @param session Session to terminate. + * @param status Final gRPC status. + * @param message Final status message; may be NULL. */ static void grpc_session_send_trailers(cwist_grpc_session *session, cwist_grpc_status_t status, const char *message) { char status_buf[16]; @@ -1254,6 +1680,17 @@ static void grpc_session_send_trailers(cwist_grpc_session *session, cwist_grpc_s pthread_mutex_unlock(&session->wmu); } +/** @brief write_frame hook: emit a framed message as an HTTP/2 DATA frame. + * + * Flushes the delayed Response-Headers first, then sends @p frame. @p + * end_stream is ignored; stream termination happens via the trailers path. + * + * @param ctx cwist_grpc_session. + * @param frame Framed gRPC message bytes. + * @param frame_len Frame length in bytes. + * @param end_stream Unused. + * @retval 0 DATA frame sent. + * @retval -1 Transport detached or trailers already sent. */ static int grpc_session_write_frame(void *ctx, const uint8_t *frame, size_t frame_len, int end_stream) { (void)end_stream; @@ -1266,6 +1703,14 @@ static int grpc_session_write_frame(void *ctx, const uint8_t *frame, size_t fram return rc; } +/** @brief Handler thread main: run the streaming handler, then finalize. + * + * Invokes the route's stream handler; if the handler returned without closing + * the stream, closes it with the stream's current status. Releases the + * session reference on exit. + * + * @param arg cwist_grpc_session. + * @return Always NULL. */ static void *grpc_session_thread(void *arg) { cwist_grpc_session *session = arg; session->handler(&session->stream, session->user_ctx); @@ -1277,6 +1722,19 @@ static void *grpc_session_thread(void *arg) { return NULL; } +/** @brief Receive the next inbound message on a stream. + * + * Buffered path: pops the next pre-decoded message, enforcing the deadline + * first. Transport path: blocks on the session queue until a message + * arrives, the call is cancelled, EOF is reached, or the deadline expires. + * + * @param stream Stream to read from; may be NULL. + * @param out Receives the message (borrowed pointer valid until the next + * recv on this stream). + * @retval 1 A message was delivered. + * @retval 0 End of stream (no more messages). + * @retval -1 NULL argument, cancellation, or deadline exceeded (stream + * status set to DEADLINE_EXCEEDED in the latter case). */ int cwist_grpc_stream_recv(cwist_grpc_stream *stream, cwist_grpc_message *out) { if (!stream || !out) return -1; @@ -1344,6 +1802,13 @@ int cwist_grpc_stream_recv(cwist_grpc_stream *stream, cwist_grpc_message *out) { } } +/** @brief Check whether a stream has been cancelled. + * + * Reads the session state under the session lock when backed by a transport. + * + * @param stream Stream to check; may be NULL (treated as not cancelled). + * @retval 1 Cancelled. + * @retval 0 Still active. */ int cwist_grpc_stream_cancelled(cwist_grpc_stream *stream) { if (!stream) return 0; if (stream->cancelled) return 1; @@ -1357,6 +1822,11 @@ int cwist_grpc_stream_cancelled(cwist_grpc_stream *stream) { return 0; } +/** @brief Milliseconds remaining until the stream deadline. + * + * @param stream Stream to inspect; may be NULL (treated as no deadline). + * @return Milliseconds left, 0 when the deadline has passed, or UINT64_MAX + * when no deadline was set. */ uint64_t cwist_grpc_stream_deadline_remaining_ms(cwist_grpc_stream *stream) { if (!stream || !stream->deadline_ms) return UINT64_MAX; uint64_t now = grpc_now_ms(); @@ -1365,6 +1835,11 @@ uint64_t cwist_grpc_stream_deadline_remaining_ms(cwist_grpc_stream *stream) { /* --- HTTP/2 stream hooks --- */ +/** @brief on_conn_open hook: allocate the per-connection gRPC context. + * + * @param user_ctx cwist_app served by this connection. + * @return Newly allocated context with a single reference, or NULL on + * allocation failure (connection then proceeds without gRPC streams). */ static void *grpc_h2_on_conn_open(void *user_ctx) { cwist_grpc_h2_conn_ctx *ctx = (cwist_grpc_h2_conn_ctx *)cwist_alloc(sizeof(*ctx)); if (!ctx) return NULL; @@ -1375,10 +1850,27 @@ static void *grpc_h2_on_conn_open(void *user_ctx) { return ctx; } +/** @brief on_conn_close hook: drop the connection's context reference. + * + * @param conn_ctx Context returned by grpc_h2_on_conn_open; may be NULL. */ static void grpc_h2_on_conn_close(void *conn_ctx) { if (conn_ctx) grpc_conn_ctx_release(conn_ctx); } +/** @brief on_headers hook: start a streaming session for a gRPC call. + * + * Only applies to registered streaming routes with a live health-Watch-style + * builtin filter (builtin routes other than health Watch, e.g. unary Check + * and reflection, stay on the buffered dispatch path). Validates the + * content type and grpc-encoding, then allocates a session, wires the public + * stream (write hook, deadline from grpc-timeout, response encoding), links + * it into the connection context, and spawns the detached handler thread. + * + * @param conn_ctx cwist_grpc_h2_conn_ctx. + * @param req Fully received request headers. + * @param stream HTTP/2 stream to attach to. + * @return Session pointer used later as stream context, or NULL to leave the + * request to buffered dispatch or reject it. */ static void *grpc_h2_on_headers(void *conn_ctx, cwist_http_request *req, cwist_h2_stream *stream) { cwist_grpc_h2_conn_ctx *ctx = conn_ctx; if (!ctx || !ctx->app || !req || !req->path || !req->path->data) return NULL; @@ -1457,6 +1949,21 @@ static void *grpc_h2_on_headers(void *conn_ctx, cwist_http_request *req, cwist_h return session; } +/** @brief on_data hook: feed DATA frames into the session's decoder. + * + * Decoded messages are queued for the handler thread. A decode failure + * fails the call with UNIMPLEMENTED (compressed message without + * grpc-encoding: gzip) or INVALID_ARGUMENT (malformed frame) and error + * trailers. On end_stream the session is marked EOF, waking blocked + * receivers. + * + * @param conn_ctx Unused. + * @param stream_ctx Session from grpc_h2_on_headers. + * @param data DATA frame payload; may be NULL when @p len is 0. + * @param len Payload length. + * @param end_stream Non-zero when the client half-closed. + * @retval 0 Data accepted (or the session already failed; trailers carry it). + * @retval 1 @p stream_ctx is NULL. */ static int grpc_h2_on_data(void *conn_ctx, void *stream_ctx, const unsigned char *data, size_t len, int end_stream) { (void)conn_ctx; @@ -1486,6 +1993,13 @@ static int grpc_h2_on_data(void *conn_ctx, void *stream_ctx, const unsigned char return 0; } +/** @brief on_cancel hook: mark the session cancelled and wake the handler. + * + * No trailers are sent here; the poll sweep and handler observe the + * cancellation and terminate the call. + * + * @param conn_ctx Unused. + * @param stream_ctx Session to cancel; may be NULL. */ static void grpc_h2_on_cancel(void *conn_ctx, void *stream_ctx) { (void)conn_ctx; cwist_grpc_session *session = stream_ctx; @@ -1497,6 +2011,16 @@ static void grpc_h2_on_cancel(void *conn_ctx, void *stream_ctx) { pthread_mutex_unlock(&session->mu); } +/** @brief on_poll hook: enforce deadlines and report stream liveness. + * + * When the grpc-timeout deadline has passed and trailers have not been sent, + * cancels the session and emits DEADLINE_EXCEEDED trailers. + * + * @param conn_ctx Unused. + * @param stream_ctx Session to poll; NULL counts as finished. + * @retval 0 Session still active. + * @retval 1 Terminal trailers were sent (or no session); the transport may + * reap the stream. */ static int grpc_h2_on_poll(void *conn_ctx, void *stream_ctx) { (void)conn_ctx; cwist_grpc_session *session = stream_ctx; @@ -1516,6 +2040,14 @@ static int grpc_h2_on_poll(void *conn_ctx, void *stream_ctx) { return session->trailers_sent; } +/** @brief next_deadline_ms hook: earliest active session deadline. + * + * Lets the HTTP/2 poll loop sleep until the next deadline instead of + * polling continuously. + * + * @param conn_ctx cwist_grpc_h2_conn_ctx. + * @return Nearest session deadline in monotonic milliseconds, or 0 when no + * session has a pending deadline. */ static uint64_t grpc_h2_next_deadline_ms(void *conn_ctx) { cwist_grpc_h2_conn_ctx *ctx = conn_ctx; if (!ctx) return 0; @@ -1529,6 +2061,14 @@ static uint64_t grpc_h2_next_deadline_ms(void *conn_ctx) { return nearest; } +/** @brief on_close hook: detach the session from its transport. + * + * Clears session->h2s so handler-thread writes fail fast, cancels the + * session and wakes blocked receivers, and releases the transport's session + * reference (the handler thread holds the other one). + * + * @param conn_ctx Unused. + * @param stream_ctx Session being closed; may be NULL. */ static void grpc_h2_on_close(void *conn_ctx, void *stream_ctx) { (void)conn_ctx; cwist_grpc_session *session = stream_ctx; @@ -1556,6 +2096,10 @@ static const cwist_http2_stream_hooks grpc_h2_hooks = { .on_close = grpc_h2_on_close, }; +/** @brief Access the gRPC HTTP/2 stream hooks table. + * + * @return Static hooks structure wiring the incremental gRPC streaming + * engine into the HTTP/2 transport. */ const cwist_http2_stream_hooks *cwist_grpc_http2_hooks(void) { return &grpc_h2_hooks; } diff --git a/src/net/grpc/grpc_channel.c b/src/net/grpc/grpc_channel.c index 0dd11a34e..e5badc8c9 100644 --- a/src/net/grpc/grpc_channel.c +++ b/src/net/grpc/grpc_channel.c @@ -102,18 +102,24 @@ struct cwist_grpc_channel_call { char *syn_message; /* owned */ }; +/** @brief Monotonic clock in milliseconds (CLOCK_MONOTONIC). */ static uint64_t channel_now_ms(void) { struct timespec ts; clock_gettime(CLOCK_MONOTONIC, &ts); return (uint64_t)ts.tv_sec * 1000 + (uint64_t)ts.tv_nsec / 1000000; } +/** @brief Sleep the current thread for @p ms milliseconds. */ static void channel_sleep_ms(uint64_t ms) { if (!ms) return; struct timespec ts = {(time_t)(ms / 1000), (long)((ms % 1000) * 1000000L)}; nanosleep(&ts, NULL); } +/** @brief Advance and return the channel's xorshift64 random state. + * + * Caller must hold ch->mu. + */ static uint64_t channel_rand(cwist_grpc_channel *ch) { uint64_t x = ch->rng; x ^= x << 13; @@ -123,7 +129,9 @@ static uint64_t channel_rand(cwist_grpc_channel *ch) { return x; } -/* Uniform in [0.8, 1.2): the gRFC A6 / connection-backoff jitter factor. */ +/** @brief Uniform jitter factor in [0.8, 1.2): the gRFC A6 / + * connection-backoff jitter factor. + */ static double channel_jitter(cwist_grpc_channel *ch) { pthread_mutex_lock(&ch->mu); double u = (double)(channel_rand(ch) >> 11) * (1.0 / 9007199254740992.0); @@ -133,6 +141,12 @@ static double channel_jitter(cwist_grpc_channel *ch) { /* --- throttling (gRFC A6 retryThrottling) --- */ +/** @brief Check the gRFC A6 retry throttle: retry only while the token count + * is above half of maxTokens. + * + * @retval 1 retry permitted (or no throttle configured) + * @retval 0 throttled + */ static int channel_throttle_allows(cwist_grpc_channel *ch) { if (!ch->has_throttle) return 1; pthread_mutex_lock(&ch->tmu); @@ -141,6 +155,7 @@ static int channel_throttle_allows(cwist_grpc_channel *ch) { return ok; } +/** @brief Charge one token to the gRFC A6 retry throttle (floored at zero). */ static void channel_throttle_failure(cwist_grpc_channel *ch) { if (!ch->has_throttle) return; pthread_mutex_lock(&ch->tmu); @@ -149,6 +164,9 @@ static void channel_throttle_failure(cwist_grpc_channel *ch) { pthread_mutex_unlock(&ch->tmu); } +/** @brief Refund tokenRatio tokens to the gRFC A6 retry throttle (capped at + * maxTokens). + */ static void channel_throttle_success(cwist_grpc_channel *ch) { if (!ch->has_throttle) return; pthread_mutex_lock(&ch->tmu); @@ -157,6 +175,10 @@ static void channel_throttle_success(cwist_grpc_channel *ch) { pthread_mutex_unlock(&ch->tmu); } +/** @brief End-of-call throttle accounting (gRFC A6): refund on success, + * charge one token for a retryable-coded failure (or a do-not-retry + * pushback) under a retry policy. + */ static void channel_account(cwist_grpc_channel *ch, cwist_grpc_status_t status, int has_policy, uint32_t mask) { if (status == CWIST_GRPC_OK) { @@ -168,7 +190,10 @@ static void channel_account(cwist_grpc_channel *ch, cwist_grpc_status_t status, /* --- subchannels --- */ -/* Close a dead/GOAWAY connection so the next acquire dials fresh. */ +/** @brief Close a dead/GOAWAY connection so the next acquire dials fresh. + * + * Caller must hold ch->mu. + */ static void channel_recycle_locked(cwist_grpc_channel *ch, cwist_grpc_subchannel *sub) { (void)ch; if (sub->client && cwist_grpc_client_dead(sub->client)) { @@ -177,8 +202,17 @@ static void channel_recycle_locked(cwist_grpc_channel *ch, cwist_grpc_subchannel } } -/* Dial unless connected; applies doc/connection-backoff.md between failures. - * Caller holds ch->mu. */ +/** @brief Dial @p sub unless already connected, applying + * doc/connection-backoff.md between failures. + * + * On a failed dial the subchannel's next dial time is pushed out by the + * current backoff (with jitter) and the backoff grows geometrically; a + * successful dial resets it. + * + * @retval 0 connected (or already connected) + * @retval -1 still in backoff, or the dial failed + * @note Caller must hold ch->mu. + */ static int sub_ensure_connected(cwist_grpc_channel *ch, cwist_grpc_subchannel *sub) { channel_recycle_locked(ch, sub); if (sub->client) return 0; @@ -205,7 +239,14 @@ static int sub_ensure_connected(cwist_grpc_channel *ch, cwist_grpc_subchannel *s return 0; } -/* Caller holds ch->mu. */ +/** @brief Pick an already-connected, idle subchannel per the LB policy. + * + * round_robin rotates across the READY set; pick_first sticks to its current + * subchannel (doc/load-balancing.md). + * + * @return the picked subchannel, or NULL when none is ready + * @note Caller must hold ch->mu. + */ static cwist_grpc_subchannel *channel_pick_locked(cwist_grpc_channel *ch) { if (ch->lb == CWIST_GRPC_LB_ROUND_ROBIN) { for (size_t k = 0; k < ch->nsubs; k++) { @@ -226,9 +267,14 @@ static cwist_grpc_subchannel *channel_pick_locked(cwist_grpc_channel *ch) { return NULL; } -/* Acquire a READY subchannel (connected, idle), dialing on demand per LB - * policy. On success sub->inflight is incremented; the caller returns it - * with channel_release(). NULL = TRANSIENT_FAILURE right now. */ +/** @brief Acquire a READY subchannel (connected, idle), dialing on demand per + * the LB policy. + * + * On success sub->inflight is incremented; the caller returns it with + * channel_release(). + * + * @return the acquired subchannel, or NULL for TRANSIENT_FAILURE right now + */ static cwist_grpc_subchannel *channel_acquire(cwist_grpc_channel *ch) { pthread_mutex_lock(&ch->mu); cwist_grpc_subchannel *s = channel_pick_locked(ch); @@ -266,12 +312,14 @@ static cwist_grpc_subchannel *channel_acquire(cwist_grpc_channel *ch) { return NULL; } +/** @brief Decrement the subchannel's in-flight call count. */ static void channel_release(cwist_grpc_channel *ch, cwist_grpc_subchannel *sub) { pthread_mutex_lock(&ch->mu); if (sub->inflight > 0) sub->inflight--; pthread_mutex_unlock(&ch->mu); } +/** @brief Close a dead/GOAWAY subchannel connection, taking ch->mu. */ static void channel_recycle(cwist_grpc_channel *ch, cwist_grpc_subchannel *sub) { pthread_mutex_lock(&ch->mu); channel_recycle_locked(ch, sub); @@ -280,7 +328,11 @@ static void channel_recycle(cwist_grpc_channel *ch, cwist_grpc_subchannel *sub) /* --- method configs (JSON service config) --- */ -/* "/package.Service/Method" -> service/method slices (borrowed). */ +/** @brief Split "/package.Service/Method" into service/method slices (borrowed). + * + * @param[out] service,service_len service name slice (after any leading '/') + * @param[out] method method name slice, or NULL when @p path has no second '/' + */ static void channel_split_method(const char *path, const char **service, size_t *service_len, const char **method) { const char *start = path; @@ -297,7 +349,12 @@ static void channel_split_method(const char *path, const char **service, size_t *method = slash + 1; } -/* Most specific match wins: service+method > service > wildcard. */ +/** @brief Find the method config for a "/service/method" path. + * + * Most specific match wins: service+method > service > wildcard (empty + * service). List order is preserved at build time, so the first match of + * each kind wins. + */ static const grpc_method_config *channel_find_mc(cwist_grpc_channel *ch, const char *path) { const char *service, *method; size_t service_len; @@ -319,6 +376,7 @@ static const grpc_method_config *channel_find_mc(cwist_grpc_channel *ch, const c return service_match ? service_match : wild_match; } +/** @brief Free the channel's method config list and reset ch->mcs to NULL. */ static void channel_free_mcs(cwist_grpc_channel *ch) { grpc_method_config *mc = ch->mcs; while (mc) { @@ -333,6 +391,7 @@ static void channel_free_mcs(cwist_grpc_channel *ch) { /* --- synthetic (pre-dispatch) call results --- */ +/** @brief Duplicate a C string with cwist_alloc; NULL in -> NULL out. */ static char *channel_strdup(const char *s) { if (!s) return NULL; size_t len = strlen(s) + 1; @@ -341,6 +400,14 @@ static char *channel_strdup(const char *s) { return copy; } +/** @brief Allocate a synthetic (pre-dispatch) channel call result. + * + * The result carries no live grpc_call; finish/destroy report @p status and + * an owned copy of @p message. Throttle accounting parameters are recorded + * for the eventual end-of-call bookkeeping. + * + * @return the new handle, or NULL on allocation failure + */ static cwist_grpc_channel_call *channel_synthetic(cwist_grpc_channel *ch, cwist_grpc_status_t status, const char *message, uint32_t attempts, int has_policy, @@ -357,8 +424,11 @@ static cwist_grpc_channel_call *channel_synthetic(cwist_grpc_channel *ch, return cc; } -/* Hand a live attempt call over to the user. On allocation failure the - * attempt is torn down and the subchannel released. */ +/** @brief Hand a live attempt call over to the user. + * + * On allocation failure the attempt is torn down (call destroyed, subchannel + * released) and NULL is returned. + */ static cwist_grpc_channel_call *channel_wrap(cwist_grpc_channel *ch, cwist_grpc_subchannel *sub, cwist_grpc_call *call, uint32_t attempts, int has_policy, uint32_t mask, int accounted) { @@ -377,6 +447,22 @@ static cwist_grpc_channel_call *channel_wrap(cwist_grpc_channel *ch, cwist_grpc_ /* --- retry engine --- */ +/** @brief Start a unary RPC through the channel, running the gRFC A6 retry + * engine transparently across attempts. + * + * Applies the most specific JSON method config (retry policy, waitForReady, + * timeout), clamps attempts to GRPC_CHANNEL_MAX_ATTEMPTS_CAP, and retries + * only while the request fits the per-RPC buffer limit. Blocks until the + * RPC is committed (Response-Headers or final result), fails with + * CWIST_GRPC_DEADLINE_EXCEEDED past the deadline, or waits instead of + * failing when waitForReady is set and no subchannel is ready. + * + * @param method "/package.Service/Method" path + * @param request,request_len serialized request (may be NULL when 0) + * @param timeout_ms per-call deadline, 0 for none (a method-config timeout + * may shorten it) + * @return channel call handle, or NULL on invalid arguments/allocation failure + */ cwist_grpc_channel_call *cwist_grpc_channel_call_start(cwist_grpc_channel *ch, const char *method, const void *request, size_t request_len, uint64_t timeout_ms) { @@ -527,12 +613,27 @@ cwist_grpc_channel_call *cwist_grpc_channel_call_start(cwist_grpc_channel *ch, c } } +/** @brief Receive the next response message of a channel call. + * + * Synthetic (pre-dispatch failure) results carry no messages. + * + * @retval 1 a message was stored in @p out + * @retval 0 stream ended (or synthetic result), no more messages + * @retval -1 invalid arguments + */ int cwist_grpc_channel_call_recv(cwist_grpc_channel_call *cc, cwist_grpc_message *out) { if (!cc || !out) return -1; if (!cc->call) return 0; /* synthetic failure: no messages */ return cwist_grpc_call_recv(cc->call, out); } +/** @brief Finish a channel call and return its final gRPC status. + * + * Performs the end-of-call throttle accounting once, if not already done by + * destroy. For synthetic results, returns the pre-dispatch status and + * message. @p message is borrowed (valid until the call is destroyed) and + * may be NULL. + */ cwist_grpc_status_t cwist_grpc_channel_call_finish(cwist_grpc_channel_call *cc, const char **message) { if (!cc) { @@ -553,10 +654,17 @@ cwist_grpc_status_t cwist_grpc_channel_call_finish(cwist_grpc_channel_call *cc, return status; } +/** @brief Cancel the underlying attempt call, if it reached the transport. */ void cwist_grpc_channel_call_cancel(cwist_grpc_channel_call *cc) { if (cc && cc->call) cwist_grpc_call_cancel(cc->call); } +/** @brief Destroy a channel call and release its resources. + * + * Performs the end-of-call throttle accounting first if finish() did not + * already. Destroys the underlying call, recycles/releases the subchannel, + * and frees the handle. NULL-safe. + */ void cwist_grpc_channel_call_destroy(cwist_grpc_channel_call *cc) { if (!cc) return; if (!cc->accounted) { @@ -573,10 +681,22 @@ void cwist_grpc_channel_call_destroy(cwist_grpc_channel_call *cc) { cwist_free(cc); } +/** @brief Number of policy attempts a channel call used (transparent retries excluded). */ uint32_t cwist_grpc_channel_call_attempts(const cwist_grpc_channel_call *cc) { return cc ? cc->attempts : 0; } +/** @brief Convenience blocking unary RPC: start, read one response, finish. + * + * On CWIST_GRPC_OK the first response message is copied into @p response + * (caller-owned; freed with cwist_free) with its length in @p response_len; + * extra messages are discarded. On any other status the response is freed + * and NULL/0 is returned. Output pointers may be NULL to discard the + * corresponding value. @p status_message, when non-NULL on entry, receives + * an allocated copy of the server status message. + * + * @return the final gRPC status (CWIST_GRPC_INTERNAL if the call could not start) + */ cwist_grpc_status_t cwist_grpc_channel_unary(cwist_grpc_channel *ch, const char *method, const void *request, size_t request_len, uint64_t timeout_ms, uint8_t **response, @@ -617,8 +737,16 @@ cwist_grpc_status_t cwist_grpc_channel_unary(cwist_grpc_channel *ch, const char /* --- name resolution (doc/naming.md) --- */ -/* Split "host[:port]" or "[v6][:port]"; bare multi-colon names are IPv6 - * literals without a port. Default port 443 (naming.md). */ +/** @brief Split "host[:port]" or "[v6][:port]" into host and port. + * + * A bare multi-colon name is an IPv6 literal without a port. The default + * port is 443 (naming.md). + * + * @param[out] host NUL-terminated host, must hold at least @p host_cap bytes + * @param[out] port parsed port (443 when absent) + * @retval 0 parsed + * @retval -1 malformed name or host buffer too small + */ static int channel_split_host_port(const char *s, size_t len, char *host, size_t host_cap, uint16_t *port) { *port = 443; @@ -666,6 +794,11 @@ static int channel_split_host_port(const char *s, size_t len, char *host, size_t return 0; } +/** @brief Append a backend address as a new subchannel, ignoring duplicates. + * + * @retval 0 address added (or already present) + * @retval -1 address table full or allocation failure + */ static int channel_add_addr(cwist_grpc_channel *ch, const char *host, uint16_t port) { for (size_t i = 0; i < ch->nsubs; i++) if (ch->subs[i].port == port && strcmp(ch->subs[i].host, host) == 0) @@ -681,6 +814,14 @@ static int channel_add_addr(cwist_grpc_channel *ch, const char *host, uint16_t p return 0; } +/** @brief Resolve a DNS name ("host[:port]") into numeric subchannel addresses. + * + * On success the DNS host becomes the TLS SNI name (when not already set) + * and "host:port" becomes the :authority (when not already set). + * + * @retval 0 at least one address was added + * @retval -1 parse or resolution failure (or no usable result) + */ static int channel_resolve_dns(cwist_grpc_channel *ch, const char *name) { char host[256]; uint16_t port; @@ -711,6 +852,15 @@ static int channel_resolve_dns(cwist_grpc_channel *ch, const char *name) { return rc; } +/** @brief Resolve a comma-separated ipv4:/ipv6: literal address list. + * + * Unparseable entries are skipped. On success the first added address + * becomes the TLS SNI name and :authority when those are not already set. + * + * @param v6 parse tokens as IPv6 (ipv6: scheme) instead of IPv4 + * @retval 0 at least one address was added + * @retval -1 nothing parsed + */ static int channel_resolve_literal(cwist_grpc_channel *ch, const char *list, int v6) { char *copy = channel_strdup(list); if (!copy) return -1; @@ -737,6 +887,17 @@ static int channel_resolve_literal(cwist_grpc_channel *ch, const char *list, int /* --- channel lifecycle --- */ +/** @brief Create a channel for @p target and resolve its backend addresses. + * + * Supported targets: "dns:name" (a "//authority/" prefix is ignored, as in + * gRPC C-core), "ipv4:1.2.3.4:port,...", "ipv6:[...]:port,...", and a bare + * name (treated as dns:). unix:, unix-abstract:, and vsock: are not + * implemented and fail. round_robin dials every address up front; + * pick_first stays IDLE until the first RPC. + * + * @param options may be NULL for defaults; see cwist_grpc_channel_options + * @return the new channel, or NULL on any failure (fully cleaned up) + */ cwist_grpc_channel *cwist_grpc_channel_connect(const char *target, const cwist_grpc_channel_options *options) { if (!target || !*target) return NULL; @@ -811,6 +972,10 @@ cwist_grpc_channel *cwist_grpc_channel_connect(const char *target, return NULL; } +/** @brief Close a channel and free all its resources. + * + * Calls handed out must be destroyed before closing the channel. NULL-safe. + */ void cwist_grpc_channel_close(cwist_grpc_channel *ch) { if (!ch) return; /* Calls handed out must be destroyed before closing the channel. */ @@ -827,6 +992,12 @@ void cwist_grpc_channel_close(cwist_grpc_channel *ch) { cwist_free(ch); } +/** @brief Aggregate connectivity state across subchannels (load-balancing.md). + * + * READY if any subchannel is connected, else IDLE while any subchannel may + * dial, else TRANSIENT_FAILURE. NULL maps to SHUTDOWN. Thread-safe: takes + * ch->mu. + */ cwist_grpc_channel_state cwist_grpc_channel_get_state(cwist_grpc_channel *ch) { if (!ch) return CWIST_GRPC_CHANNEL_SHUTDOWN; uint64_t now = channel_now_ms(); @@ -856,6 +1027,12 @@ static const char *const grpc_status_names[] = { "ABORTED", "OUT_OF_RANGE", "UNIMPLEMENTED", "INTERNAL", "UNAVAILABLE", "DATA_LOSS", "UNAUTHENTICATED"}; +/** @brief Map a JSON status code (number 0-16 or canonical name) to its mask bit. + * + * @param[out] bit CWIST_GRPC_STATUS_BIT(code) on success + * @retval 0 recognized code + * @retval -1 unknown number or name + */ static int grpc_status_from_json(const cJSON *item, uint32_t *bit) { if (cJSON_IsNumber(item)) { int code = (int)item->valuedouble; @@ -874,7 +1051,14 @@ static int grpc_status_from_json(const cJSON *item, uint32_t *bit) { return -1; } -/* proto3 JSON Duration: a decimal number with an "s" suffix. */ +/** @brief Parse a proto3 JSON Duration ("1.5s") into milliseconds. + * + * The value is rounded to the nearest millisecond, minimum 1 ms. + * + * @param[out] out_ms parsed duration in milliseconds + * @retval 0 parsed + * @retval -1 not a string, missing "s" suffix, or non-positive value + */ static int grpc_json_duration_ms(const cJSON *item, uint64_t *out_ms) { if (!cJSON_IsString(item)) return -1; const char *s = item->valuestring; @@ -888,8 +1072,17 @@ static int grpc_json_duration_ms(const cJSON *item, uint64_t *out_ms) { return 0; } -/* A6 retryPolicy validation: maxAttempts integer > 1 (clamped to 5), - * positive backoffs/multiplier, non-empty retryableStatusCodes. */ +/** @brief Validate and parse an A6 retryPolicy JSON object. + * + * maxAttempts must be an integer > 1 (clamped to + * GRPC_CHANNEL_MAX_ATTEMPTS_CAP, per A6 no validation error), backoffs must + * be positive Durations, the multiplier positive, and + * retryableStatusCodes a non-empty array of valid codes. + * + * @param[out] out parsed policy on success + * @retval 0 valid policy + * @retval -1 invalid policy + */ static int grpc_json_retry_policy(const cJSON *obj, cwist_grpc_retry_policy *out) { const cJSON *max_attempts = cJSON_GetObjectItemCaseSensitive(obj, "maxAttempts"); const cJSON *initial = cJSON_GetObjectItemCaseSensitive(obj, "initialBackoff"); @@ -922,6 +1115,7 @@ static int grpc_json_retry_policy(const cJSON *obj, cwist_grpc_retry_policy *out return 0; } +/** @brief Free a method config list built during JSON parsing. */ static void grpc_json_free_mc_list(grpc_method_config *list) { while (list) { grpc_method_config *next = list->next; @@ -932,6 +1126,17 @@ static void grpc_json_free_mc_list(grpc_method_config *list) { } } +/** @brief Apply a JSON service config (doc/service_config.md subset, gRFC A6). + * + * Parses loadBalancingConfig (first supported policy wins; deprecated + * loadBalancingPolicy string also accepted), retryThrottling, and methodConfig + * (retryPolicy, waitForReady, timeout per name; hedgingPolicy entries carry + * no policy, as in gRPC C-core). The whole document is validated before + * anything is swapped in; on error the channel is left untouched. + * + * @retval 0 applied + * @retval -1 invalid JSON, invalid document, or allocation failure + */ int cwist_grpc_channel_apply_service_config_json(cwist_grpc_channel *ch, const char *json) { if (!ch || !json) return -1; cJSON *root = cJSON_Parse(json); diff --git a/src/net/grpc/grpc_client.c b/src/net/grpc/grpc_client.c index 87c4ea95e..56a96d958 100644 --- a/src/net/grpc/grpc_client.c +++ b/src/net/grpc/grpc_client.c @@ -103,13 +103,24 @@ struct cwist_grpc_client { char *authority; }; +/** + * @brief Monotonic clock in milliseconds. + * @return Milliseconds since an unspecified epoch (CLOCK_MONOTONIC). + */ static uint64_t grpc_client_now_ms(void) { struct timespec ts; clock_gettime(CLOCK_MONOTONIC, &ts); return (uint64_t)ts.tv_sec * 1000 + (uint64_t)ts.tv_nsec / 1000000; } -/* Wait for readability/writability until the deadline; 0 ready, -1 error/timeout. */ +/** + * @brief Wait for readability/writability on the connection until the deadline. + * @param c Client whose fd is polled. + * @param events poll() event mask (POLLIN/POLLOUT). + * @param deadline_ms Monotonic deadline in ms; 0 means wait forever. + * @retval 0 The fd is ready. + * @retval -1 Error, hangup, or deadline reached. + */ static int grpc_client_wait(cwist_grpc_client *c, short events, uint64_t deadline_ms) { for (;;) { int timeout = -1; @@ -131,7 +142,14 @@ static int grpc_client_wait(cwist_grpc_client *c, short events, uint64_t deadlin } } -/* Read up to len bytes; returns the byte count, 0 on EOF, -1 on error/timeout. */ +/** + * @brief Read up to len bytes from the connection. + * @param c Client to read from. + * @param buf Destination buffer. + * @param len Maximum bytes to read. + * @param deadline_ms Monotonic deadline in ms; 0 means no timeout. + * @return Byte count read, 0 on EOF (TLS close-notify for TLS), -1 on error/timeout. + */ static ssize_t grpc_client_read_some(cwist_grpc_client *c, void *buf, size_t len, uint64_t deadline_ms) { for (;;) { @@ -163,6 +181,13 @@ static ssize_t grpc_client_read_some(cwist_grpc_client *c, void *buf, size_t len } } +/** + * @brief Read exactly len bytes from the connection. + * @param buf Destination buffer, fully filled on success. + * @param deadline_ms Monotonic deadline in ms; 0 means no timeout. + * @retval 0 All bytes were read. + * @retval -1 EOF, error, or deadline reached. + */ static int grpc_client_read_all(cwist_grpc_client *c, void *buf, size_t len, uint64_t deadline_ms) { uint8_t *p = buf; size_t got = 0; @@ -174,6 +199,14 @@ static int grpc_client_read_all(cwist_grpc_client *c, void *buf, size_t len, uin return 0; } +/** + * @brief Write the whole buffer to the connection, retrying partial writes. + * @param buf Source buffer. + * @param len Bytes to write. + * @param deadline_ms Monotonic deadline in ms; 0 means no timeout. + * @retval 0 All bytes were written. + * @retval -1 Write error or deadline reached. + */ static int grpc_client_write_all(cwist_grpc_client *c, const void *buf, size_t len, uint64_t deadline_ms) { const uint8_t *p = buf; @@ -208,6 +241,18 @@ static int grpc_client_write_all(cwist_grpc_client *c, const void *buf, size_t l return 0; } +/** + * @brief Write one HTTP/2 frame (9-byte header + optional payload). + * @param c Client to write to. + * @param type HTTP/2 frame type byte. + * @param flags HTTP/2 frame flags byte. + * @param stream_id Stream identifier (top bit masked off). + * @param payload Frame payload; may be NULL when len is 0. + * @param len Payload length in bytes. + * @param deadline_ms Monotonic deadline in ms; 0 means no timeout. + * @retval 0 Frame written. + * @retval -1 Write error or deadline reached. + */ static int grpc_client_write_frame(cwist_grpc_client *c, uint8_t type, uint8_t flags, uint32_t stream_id, const void *payload, uint32_t len, uint64_t deadline_ms) { @@ -234,6 +279,14 @@ typedef struct grpc_client_frame { uint8_t *payload; /* owned; caller frees */ } grpc_client_frame; +/** + * @brief Read one HTTP/2 frame, allocating the payload. + * @param f Filled with type, flags, stream_id, len, and payload. + * @param deadline_ms Monotonic deadline in ms; 0 means no timeout. + * @return 0 on success; on failure -1 and f->payload is NULL. + * @note f->payload is owned by the caller (cwist_free) when non-NULL. + * Frames larger than GRPC_CLIENT_MAX_FRAME are rejected. + */ static int grpc_client_read_frame(cwist_grpc_client *c, grpc_client_frame *f, uint64_t deadline_ms) { uint8_t hdr[GRPC_CLIENT_FRAME_HEADER]; @@ -259,6 +312,11 @@ static int grpc_client_read_frame(cwist_grpc_client *c, grpc_client_frame *f, /* --- HPACK decoding (per-connection dynamic table) --- */ +/** + * @brief Evict oldest entries from the HPACK dynamic table until it fits a size limit. + * @param c Client owning the dynamic table. + * @param limit Target max cumulative entry size in bytes. + */ static void grpc_client_hpack_evict_to(cwist_grpc_client *c, size_t limit) { while (c->hpack_size > limit && c->hpack_head) { grpc_client_hpack_entry **pp = &c->hpack_head; @@ -272,6 +330,14 @@ static void grpc_client_hpack_evict_to(cwist_grpc_client *c, size_t limit) { } } +/** + * @brief Insert a header into the HPACK dynamic table (front), evicting as needed. + * @param c Client owning the dynamic table. + * @param name Header name (copied). + * @param value Header value (copied). + * @retval 0 Entry inserted, or entry too large for the table (table emptied, nothing inserted). + * @retval -1 Allocation failure. + */ static int grpc_client_hpack_insert(cwist_grpc_client *c, const char *name, const char *value) { size_t size = strlen(name) + strlen(value) + 32; if (size > c->hpack_cap) { @@ -298,8 +364,16 @@ static int grpc_client_hpack_insert(cwist_grpc_client *c, const char *name, cons return 0; } -/* Resolve a full HPACK index: 1..61 static, then dynamic (62 = newest). - * Returned pointers are borrowed. */ +/** + * @brief Resolve a full HPACK index: 1..61 static, then dynamic (62 = newest). + * @param c Client owning the dynamic table. + * @param index 1-based HPACK index (static table first, then dynamic newest-first). + * @param name Set to the resolved header name. + * @param value Set to the resolved header value. + * @retval 0 Found. + * @retval -1 Index out of range. + * @note Returned pointers are borrowed; do not free. + */ static int grpc_client_hpack_get(cwist_grpc_client *c, uint32_t index, const char **name, const char **value) { const cwist_http2_static_header *st = h2_static_header(index); @@ -319,6 +393,17 @@ static int grpc_client_hpack_get(cwist_grpc_client *c, uint32_t index, const cha typedef void (*grpc_client_header_cb)(void *ctx, const char *name, const char *value); +/** + * @brief Decode an HPACK header block, invoking a callback per header field. + * @param c Client owning the dynamic table (updated in place on indexed literals and + * table size updates). + * @param buf Header block bytes. + * @param len Header block length. + * @param cb Called for each decoded header with borrowed name/value pointers. + * @param ctx Opaque context passed to cb. + * @retval 0 All fields decoded. + * @retval -1 Malformed block, bad index, oversize table update, or allocation failure. + */ static int grpc_client_hpack_decode(cwist_grpc_client *c, const uint8_t *buf, size_t len, grpc_client_header_cb cb, void *ctx) { size_t pos = 0; @@ -390,6 +475,15 @@ static int grpc_client_hpack_decode(cwist_grpc_client *c, const uint8_t *buf, si /* --- request header block encoding (literal, never-indexed) --- */ +/** + * @brief Encode one literal-without-indexing header field into a header block. + * @param dst Destination buffer. + * @param cap Capacity of dst in bytes. + * @param name_index Static/dynamic name index, or 0 to send the name literally. + * @param name Header name, used only when name_index is 0. + * @param value Header value. + * @return Encoded byte count, or 0 if dst is too small. + */ static size_t grpc_client_enc_literal(uint8_t *dst, size_t cap, uint32_t name_index, const char *name, const char *value) { size_t pos = 0; @@ -410,6 +504,12 @@ static size_t grpc_client_enc_literal(uint8_t *dst, size_t cap, uint32_t name_in /* --- connection setup --- */ +/** + * @brief Open a blocking TCP connection to host:port. + * @param host Hostname or address to resolve. + * @param port TCP port number. + * @return Connected socket fd, or -1 if resolution or every connect attempt failed. + */ static int grpc_client_tcp_connect(const char *host, uint16_t port) { char port_str[8]; snprintf(port_str, sizeof(port_str), "%u", (unsigned)port); @@ -431,6 +531,15 @@ static int grpc_client_tcp_connect(const char *host, uint16_t port) { return fd; } +/** + * @brief Set up TLS on the connected socket and perform the handshake. + * @param c Client; c->fd must already be connected. + * @param host Server name for SNI. + * @param verify_peer Nonzero to verify the peer certificate against default paths. + * @param deadline_ms Monotonic handshake deadline in ms. + * @retval 0 Handshake done and ALPN negotiated "h2". + * @retval -1 Setup, handshake, or ALPN negotiation failure. + */ static int grpc_client_tls_setup(cwist_grpc_client *c, const char *host, int verify_peer, uint64_t deadline_ms) { c->ssl_ctx = SSL_CTX_new(TLS_client_method()); @@ -466,7 +575,14 @@ static int grpc_client_tls_setup(cwist_grpc_client *c, const char *host, int ver return 0; } -/* Apply one server SETTINGS payload; answers with an ACK. */ +/** + * @brief Apply one server SETTINGS payload; answers with a SETTINGS ACK. + * @param payload SETTINGS payload; must be a multiple of 6 bytes. + * @param len Payload length in bytes. + * @param deadline_ms Monotonic deadline for the ACK write. + * @retval 0 Settings applied and ACK written. + * @retval -1 Malformed or unsupported setting value, or ACK write failure. + */ static int grpc_client_apply_settings(cwist_grpc_client *c, const uint8_t *payload, uint32_t len, uint64_t deadline_ms) { if (len % 6 != 0) return -1; @@ -493,6 +609,18 @@ static int grpc_client_apply_settings(cwist_grpc_client *c, const uint8_t *paylo return grpc_client_write_frame(c, H2_FRAME_SETTINGS, H2_FLAG_ACK, 0, NULL, 0, deadline_ms); } +/** + * @brief Connect to a gRPC server and complete the HTTP/2 handshake. + * @param host Server hostname or address. + * @param port TCP port number. + * @param options Optional TLS/timeout/authority options; NULL for defaults + * (no TLS, 10 s connect timeout, authority "host:port"). + * @return Connected client on success; NULL on any failure (partially built + * state is cleaned up before returning). + * @note Performs TCP connect, optional TLS with ALPN "h2", sends the client + * preface and SETTINGS, and waits for the server SETTINGS and the ACK + * of our own SETTINGS. + */ cwist_grpc_client *cwist_grpc_client_connect(const char *host, uint16_t port, const cwist_grpc_client_options *options) { if (!host || !port) return NULL; @@ -566,6 +694,10 @@ cwist_grpc_client *cwist_grpc_client_connect(const char *host, uint16_t port, return NULL; } +/** + * @brief Close the client: send GOAWAY, tear down TLS and the socket, free all state. + * @param c Client to close; NULL is a no-op. + */ void cwist_grpc_client_close(cwist_grpc_client *c) { if (!c) return; if (c->active) cwist_grpc_call_destroy(c->active); @@ -585,6 +717,13 @@ void cwist_grpc_client_close(cwist_grpc_client *c) { /* --- call plumbing --- */ +/** + * @brief Decoder callback: append a received gRPC message to the call's queue. + * @param ctx The cwist_grpc_call being filled. + * @param message Decoded message; data is copied. + * @retval 0 Queued. + * @retval -1 Allocation failure. + */ static int grpc_client_queue_msg(void *ctx, const cwist_grpc_message *message) { cwist_grpc_call *call = ctx; grpc_client_msg *node = cwist_alloc(sizeof(*node)); @@ -610,6 +749,17 @@ typedef struct grpc_client_headers_ctx { int http_status; } grpc_client_headers_ctx; +/** + * @brief HPACK callback: record a response header relevant to the call. + * + * Handles :status (rejects non-3-digit values), grpc-status (malformed becomes + * UNKNOWN), grpc-message (replaces the stored status message), and + * grpc-retry-pushback-ms (negative/unparseable means "do not retry"). + * + * @param ctx grpc_client_headers_ctx carrying the call and the HTTP status slot. + * @param name Borrowed header name. + * @param value Borrowed header value. + */ static void grpc_client_on_header(void *ctx, const char *name, const char *value) { grpc_client_headers_ctx *hc = ctx; if (strcmp(name, ":status") == 0) { @@ -643,8 +793,20 @@ static void grpc_client_on_header(void *ctx, const char *name, const char *value } } -/* Read one header block (HEADERS + any CONTINUATION) for the call stream. - * Consumes the already-read first frame. */ +/** + * @brief Read one header block (HEADERS + any CONTINUATION) for the call stream. + * + * Consumes the already-read first frame and frees its payload; CONTINUATION + * payloads are freed as they are consumed. + * + * @param first Already-read HEADERS frame; its flags/payload are consumed. + * @param out Set to the assembled (owned) header block; *out may be length 0. + * @param out_len Set to the block length. + * @param end_stream Set when the block carries END_STREAM. + * @param deadline_ms Monotonic deadline in ms. + * @retval 0 Block assembled; caller owns *out. + * @retval -1 Transport error, unexpected frame, or allocation failure. + */ static int grpc_client_read_header_block(cwist_grpc_client *c, cwist_grpc_call *call, grpc_client_frame *first, uint8_t **out, size_t *out_len, int *end_stream, uint64_t deadline_ms) { @@ -678,8 +840,19 @@ static int grpc_client_read_header_block(cwist_grpc_client *c, cwist_grpc_call * return 0; } -/* Process one inbound frame during a call. Returns 0 to keep pumping, - * 1 when the call stream ended (trailers or RST), -1 on error/deadline. */ +/** + * @brief Process one inbound frame during a call. + * + * Answers PING, applies SETTINGS, credits WINDOW_UPDATE, and tracks trailers, + * RST_STREAM and GOAWAY. Updates call state (status, failed, stream_ended, + * retry flags) in place; on deadline or transport failure the call is marked + * failed and the stream is reset. + * + * @param call The active call. + * @retval 0 Keep pumping. + * @retval 1 The call stream ended (trailers or RST_STREAM). + * @retval -1 Error or deadline. + */ static int grpc_client_pump(cwist_grpc_call *call) { cwist_grpc_client *c = call->client; if (call->deadline_ms && grpc_client_now_ms() >= call->deadline_ms) goto deadline; @@ -867,8 +1040,19 @@ static int grpc_client_pump(cwist_grpc_call *call) { return -1; } -/* Send the framed request message, honouring flow control coarsely: pump - * inbound frames while the peer's windows are too small. */ +/** + * @brief Send the framed request message, honouring flow control coarsely. + * + * Writes DATA frames up to the minimum of the connection and stream send + * windows; while the windows are too small, pumps inbound frames so the peer + * can open them. + * + * @param call The active call. + * @param frame Encoded gRPC message frame (not owned). + * @param frame_len Frame length in bytes. + * @retval 0 Entire frame sent (final DATA frame carries END_STREAM). + * @retval -1 Write error, deadline, or the stream ended while blocked. + */ static int grpc_client_send_request(cwist_grpc_call *call, const uint8_t *frame, size_t frame_len) { cwist_grpc_client *c = call->client; size_t sent = 0; @@ -896,12 +1080,35 @@ static int grpc_client_send_request(cwist_grpc_call *call, const uint8_t *frame, return 0; } +/** + * @brief Start a unary call (no previous-attempt marker). + * @param c Client; must be connected and have no active call. + * @param method gRPC method path (e.g. "/pkg.Service/Method"). + * @param request Request message bytes; may be NULL when request_len is 0. + * @param request_len Request length in bytes. + * @param timeout_ms Per-call deadline in ms; 0 means no deadline. + * @return The started call, or NULL on invalid arguments, a busy/dead client, + * or a transport error while sending. + * @see cwist_grpc_call_start_ex + */ cwist_grpc_call *cwist_grpc_call_start(cwist_grpc_client *c, const char *method, const void *request, size_t request_len, uint64_t timeout_ms) { return cwist_grpc_call_start_ex(c, method, request, request_len, timeout_ms, 0); } +/** + * @brief Start a unary call, with a gRFC A6 retry-attempt marker. + * + * Sends HEADERS (:method POST, :scheme, :path, :authority, content-type, te) + * followed by the framed request message, then commits the call as the + * client's active call. Serialized by the client mutex: one active call per + * connection. + * + * @param previous_attempts Value for grpc-previous-rpc-attempts; 0 omits the header. + * @return The started call, or NULL on invalid arguments, a busy/dead client, + * or a transport error while sending (state is cleaned up). + */ cwist_grpc_call *cwist_grpc_call_start_ex(cwist_grpc_client *c, const char *method, const void *request, size_t request_len, uint64_t timeout_ms, uint32_t previous_attempts) { @@ -984,6 +1191,15 @@ cwist_grpc_call *cwist_grpc_call_start_ex(cwist_grpc_client *c, const char *meth return NULL; } +/** + * @brief Receive the next response message of a call. + * @param call The call. + * @param out Filled with the next message; out->data is owned by the call and + * stays valid until the next recv or cwist_grpc_call_destroy. + * @retval 1 A message was returned. + * @retval 0 The stream ended cleanly and no more messages will arrive. + * @retval -1 The call failed (see cwist_grpc_call_status). + */ int cwist_grpc_call_recv(cwist_grpc_call *call, cwist_grpc_message *out) { if (!call || !out) return -1; while (!call->msg_head) { @@ -1002,6 +1218,13 @@ int cwist_grpc_call_recv(cwist_grpc_call *call, cwist_grpc_message *out) { return 1; } +/** + * @brief Block until response headers arrive or the stream ends. + * @param call The call. + * @retval 0 Response headers received (the RPC is committed). + * @retval 1 The stream ended before any response headers (retryable). + * @retval -1 Invalid call, error, or deadline. + */ int cwist_grpc_call_await_headers(cwist_grpc_call *call) { if (!call) return -1; while (!call->headers_received && !call->stream_ended) { @@ -1011,29 +1234,60 @@ int cwist_grpc_call_await_headers(cwist_grpc_call *call) { return call->headers_received ? 0 : 1; } +/** + * @brief Whether response headers have committed the RPC (gRFC A6). + * @return Nonzero if committed; 0 for a NULL call or not yet committed. + */ int cwist_grpc_call_committed(const cwist_grpc_call *call) { return call && call->headers_received; } +/** + * @brief Whether the call failed before commit in a retryable way (gRFC A6 + * case 3: REFUSED_STREAM before headers, or GOAWAY below our stream id). + * @return Nonzero if retryable; 0 otherwise (NULL, committed, or trailers seen). + */ int cwist_grpc_call_refused(const cwist_grpc_call *call) { if (!call || call->headers_received || call->trailers) return 0; return call->refused_stream || call->goaway_refused; } +/** + * @brief Report the server-provided retry pushback delay, if any. + * @param call The call. + * @param out_ms Set to the pushback delay in ms (negative = do not retry) when present. + * @return Nonzero if the grpc-retry-pushback-ms trailer arrived; 0 otherwise. + */ int cwist_grpc_call_retry_pushback_ms(const cwist_grpc_call *call, int32_t *out_ms) { if (!call || !call->pushback_seen) return 0; if (out_ms) *out_ms = call->pushback_ms; return 1; } +/** + * @brief Whether the client connection is unusable. + * @return Nonzero if the client is NULL, the transport died, or GOAWAY arrived. + */ int cwist_grpc_client_dead(cwist_grpc_client *c) { return !c || c->dead || c->goaway; } +/** + * @brief Current gRPC status code of a call. + * @param call The call. + * @return The status code; CWIST_GRPC_INTERNAL for a NULL call. + */ cwist_grpc_status_t cwist_grpc_call_status(const cwist_grpc_call *call) { return call ? call->status : CWIST_GRPC_INTERNAL; } +/** + * @brief Drain the call to completion and return its final status. + * @param call The call. + * @param message When non-NULL, set to the status message text; the pointer is + * owned by the call and stays valid until cwist_grpc_call_destroy. + * @return The final gRPC status code; CWIST_GRPC_INTERNAL for a NULL call. + */ cwist_grpc_status_t cwist_grpc_call_finish(cwist_grpc_call *call, const char **message) { if (!call) { if (message) *message = NULL; @@ -1046,6 +1300,11 @@ cwist_grpc_status_t cwist_grpc_call_finish(cwist_grpc_call *call, const char **m return call->status; } +/** + * @brief Cancel a call: send RST_STREAM(CANCEL), mark it failed with + * CWIST_GRPC_CANCELLED, and end its stream. + * @param call The call; NULL or an already-ended call is a no-op. + */ void cwist_grpc_call_cancel(cwist_grpc_call *call) { if (!call || call->stream_ended) return; cwist_grpc_client *c = call->client; @@ -1056,6 +1315,11 @@ void cwist_grpc_call_cancel(cwist_grpc_call *call) { call->status = CWIST_GRPC_CANCELLED; } +/** + * @brief Destroy a call: cancel it if still open, release it as the client's + * active call, and free all queued and buffered state. + * @param call The call; NULL is a no-op. + */ void cwist_grpc_call_destroy(cwist_grpc_call *call) { if (!call) return; cwist_grpc_client *c = call->client; diff --git a/src/net/grpc/protobuf.c b/src/net/grpc/protobuf.c index 97983a312..14c4d5198 100644 --- a/src/net/grpc/protobuf.c +++ b/src/net/grpc/protobuf.c @@ -10,6 +10,10 @@ #include #include +/** + * @brief Initialize a Protobuf writer to an empty buffer. + * @param w Writer to reset. NULL is accepted and ignored. + */ void cwist_pb_writer_init(cwist_pb_writer *w) { if (!w) return; w->data = NULL; @@ -17,6 +21,10 @@ void cwist_pb_writer_init(cwist_pb_writer *w) { w->cap = 0; } +/** + * @brief Release the buffer owned by a Protobuf writer. + * @param w Writer whose data is freed and reset. NULL is accepted and ignored. + */ void cwist_pb_writer_free(cwist_pb_writer *w) { if (!w) return; cwist_free(w->data); @@ -25,6 +33,12 @@ void cwist_pb_writer_free(cwist_pb_writer *w) { w->cap = 0; } +/** + * @brief Ensure the writer buffer has room for @p extra more bytes. + * @param w Writer to grow. Must not be NULL. + * @param extra Number of additional bytes required beyond the current length. + * @return 0 on success, -1 on invalid argument, overflow, or allocation failure. + */ int cwist_pb_writer_reserve(cwist_pb_writer *w, size_t extra) { if (!w) return -1; if (extra > SIZE_MAX - w->len) return -1; @@ -46,6 +60,12 @@ int cwist_pb_writer_reserve(cwist_pb_writer *w, size_t extra) { return 0; } +/** + * @brief Append a base-128 varint to the writer buffer. + * @param w Writer to append to. Must not be NULL. + * @param value Value to encode. + * @return 0 on success, -1 on invalid argument or allocation failure. + */ int cwist_pb_write_varint(cwist_pb_writer *w, uint64_t value) { if (!w) return -1; if (cwist_pb_writer_reserve(w, 10) != 0) return -1; @@ -57,6 +77,13 @@ int cwist_pb_write_varint(cwist_pb_writer *w, uint64_t value) { return 0; } +/** + * @brief Write a field key (field number and wire type) as a varint. + * @param w Writer to append to. Must not be NULL. + * @param field_number Protobuf field number; must be non-zero. + * @param wire_type Wire type; must be a valid cwist_pb_wire_type_t value. + * @return 0 on success, -1 on invalid argument or allocation failure. + */ int cwist_pb_write_key(cwist_pb_writer *w, uint32_t field_number, cwist_pb_wire_type_t wire_type) { if (!w || field_number == 0) return -1; if (wire_type != CWIST_PB_VARINT && wire_type != CWIST_PB_64BIT && wire_type != CWIST_PB_LEN && @@ -67,19 +94,46 @@ int cwist_pb_write_key(cwist_pb_writer *w, uint32_t field_number, cwist_pb_wire_ return cwist_pb_write_varint(w, key); } +/** + * @brief Write a uint64 field as a varint. + * @param w Writer to append to. + * @param field_number Protobuf field number; must be non-zero. + * @param value Value to encode. + * @return 0 on success, -1 on invalid argument or allocation failure. + */ int cwist_pb_write_uint64_field(cwist_pb_writer *w, uint32_t field_number, uint64_t value) { if (cwist_pb_write_key(w, field_number, CWIST_PB_VARINT) != 0) return -1; return cwist_pb_write_varint(w, value); } +/** + * @brief Write an int64 field; negative values are encoded as ten-byte varints. + * @param w Writer to append to. + * @param field_number Protobuf field number; must be non-zero. + * @param value Value to encode (two's-complement, reinterpreted as uint64). + * @return 0 on success, -1 on invalid argument or allocation failure. + */ int cwist_pb_write_int64_field(cwist_pb_writer *w, uint32_t field_number, int64_t value) { return cwist_pb_write_uint64_field(w, field_number, (uint64_t)value); } +/** + * @brief Write a bool field as a 0/1 varint. + * @param w Writer to append to. + * @param field_number Protobuf field number; must be non-zero. + * @param value Non-zero encodes as 1, zero encodes as 0. + * @return 0 on success, -1 on invalid argument or allocation failure. + */ int cwist_pb_write_bool_field(cwist_pb_writer *w, uint32_t field_number, int value) { return cwist_pb_write_uint64_field(w, field_number, value ? 1 : 0); } +/** + * @brief Append a 32-bit value in little-endian order (no key written). + * @param w Writer to append to. Must not be NULL. + * @param value Value to encode. + * @return 0 on success, -1 on invalid argument or allocation failure. + */ int cwist_pb_write_raw32(cwist_pb_writer *w, uint32_t value) { if (!w) return -1; if (cwist_pb_writer_reserve(w, 4) != 0) return -1; @@ -90,6 +144,12 @@ int cwist_pb_write_raw32(cwist_pb_writer *w, uint32_t value) { return 0; } +/** + * @brief Append a 64-bit value in little-endian order (no key written). + * @param w Writer to append to. Must not be NULL. + * @param value Value to encode. + * @return 0 on success, -1 on invalid argument or allocation failure. + */ int cwist_pb_write_raw64(cwist_pb_writer *w, uint64_t value) { if (!w) return -1; if (cwist_pb_writer_reserve(w, 8) != 0) return -1; @@ -97,24 +157,61 @@ int cwist_pb_write_raw64(cwist_pb_writer *w, uint64_t value) { return 0; } +/** + * @brief Write a fixed32 field (key plus 4 little-endian bytes). + * @param w Writer to append to. + * @param field_number Protobuf field number; must be non-zero. + * @param value Value to encode. + * @return 0 on success, -1 on invalid argument or allocation failure. + */ int cwist_pb_write_fixed32_field(cwist_pb_writer *w, uint32_t field_number, uint32_t value) { if (cwist_pb_write_key(w, field_number, CWIST_PB_32BIT) != 0) return -1; return cwist_pb_write_raw32(w, value); } +/** + * @brief Write a fixed64 field (key plus 8 little-endian bytes). + * @param w Writer to append to. + * @param field_number Protobuf field number; must be non-zero. + * @param value Value to encode. + * @return 0 on success, -1 on invalid argument or allocation failure. + */ int cwist_pb_write_fixed64_field(cwist_pb_writer *w, uint32_t field_number, uint64_t value) { if (cwist_pb_write_key(w, field_number, CWIST_PB_64BIT) != 0) return -1; return cwist_pb_write_raw64(w, value); } +/** + * @brief Write a float field by reinterpreting it as fixed32 bits. + * @param w Writer to append to. + * @param field_number Protobuf field number; must be non-zero. + * @param value Value to encode. + * @return 0 on success, -1 on invalid argument or allocation failure. + */ int cwist_pb_write_float_field(cwist_pb_writer *w, uint32_t field_number, float value) { return cwist_pb_write_fixed32_field(w, field_number, cwist_pb_float_bits(value)); } +/** + * @brief Write a double field by reinterpreting it as fixed64 bits. + * @param w Writer to append to. + * @param field_number Protobuf field number; must be non-zero. + * @param value Value to encode. + * @return 0 on success, -1 on invalid argument or allocation failure. + */ int cwist_pb_write_double_field(cwist_pb_writer *w, uint32_t field_number, double value) { return cwist_pb_write_fixed64_field(w, field_number, cwist_pb_double_bits(value)); } +/** + * @brief Write a length-delimited bytes field (key, length varint, raw bytes). + * @param w Writer to append to. + * @param field_number Protobuf field number; must be non-zero. + * @param data Bytes to copy; may be NULL only when @p len is 0. The bytes are + * copied into the writer buffer, so the caller keeps ownership. + * @param len Number of bytes to write. + * @return 0 on success, -1 on invalid argument or allocation failure. + */ int cwist_pb_write_bytes_field(cwist_pb_writer *w, uint32_t field_number, const void *data, size_t len) { if (!w || (len > 0 && !data)) return -1; @@ -126,11 +223,24 @@ int cwist_pb_write_bytes_field(cwist_pb_writer *w, uint32_t field_number, const return 0; } +/** + * @brief Write a string field (length-delimited, excluding the NUL terminator). + * @param w Writer to append to. + * @param field_number Protobuf field number; must be non-zero. + * @param value NUL-terminated string to copy; must not be NULL. + * @return 0 on success, -1 on invalid argument or allocation failure. + */ int cwist_pb_write_string_field(cwist_pb_writer *w, uint32_t field_number, const char *value) { if (!value) return -1; return cwist_pb_write_bytes_field(w, field_number, value, strlen(value)); } +/** + * @brief Initialize a reader over a caller-owned buffer. + * @param r Reader to initialize. NULL is accepted and ignored. + * @param data Buffer to read from; not copied, so it must outlive the reader. + * @param len Size of @p data in bytes. + */ void cwist_pb_reader_init(cwist_pb_reader *r, const void *data, size_t len) { if (!r) return; r->data = (const uint8_t *)data; @@ -138,6 +248,13 @@ void cwist_pb_reader_init(cwist_pb_reader *r, const void *data, size_t len) { r->pos = 0; } +/** + * @brief Read a base-128 varint, advancing the reader position. + * @param r Reader to read from. Must not be NULL. + * @param out Receives the decoded value. Must not be NULL. + * @return 0 on success, -1 if the reader is exhausted or the varint is + * malformed (unterminated or longer than 64 bits). + */ int cwist_pb_read_varint(cwist_pb_reader *r, uint64_t *out) { if (!r || !out) return -1; uint64_t value = 0; @@ -154,6 +271,16 @@ int cwist_pb_read_varint(cwist_pb_reader *r, uint64_t *out) { return -1; } +/** + * @brief Read the next field key and payload from the reader. + * @param r Reader to read from. Must not be NULL. + * @param field Receives the field number, wire type, and payload descriptor. + * Must not be NULL. + * @retval 1 A field was read and @p field is filled in. + * @retval 0 The reader is exhausted (end of input); the reader position is + * unchanged in this case. + * @retval -1 Malformed data or invalid argument. + */ int cwist_pb_read_field(cwist_pb_reader *r, cwist_pb_field *field) { if (!r || !field || r->pos >= r->len) return 0; memset(field, 0, sizeof(*field)); @@ -193,15 +320,34 @@ int cwist_pb_read_field(cwist_pb_reader *r, cwist_pb_field *field) { } } +/** + * @brief Validate a parsed field descriptor. + * @note The reader position is already advanced by cwist_pb_read_field(), so + * this function only checks that @p field is non-NULL. + * @param r Reader associated with the field; unused. + * @param field Field descriptor to validate. Must not be NULL. + * @return 0 if @p field is valid, -1 if it is NULL. + */ int cwist_pb_skip_field(cwist_pb_reader *r, const cwist_pb_field *field) { (void)r; return field ? 0 : -1; } +/** + * @brief ZigZag-encode a signed 64-bit value for sint fields. + * @param value Signed value to encode. + * @return The ZigZag-encoded unsigned value: small magnitudes map to small + * unsigned codes regardless of sign (0, -1, 1, -2, 2 -> 0, 1, 2, 3, 4). + */ uint64_t cwist_pb_zigzag_encode(int64_t value) { return ((uint64_t)value << 1) ^ (uint64_t)(value >> 63); } +/** + * @brief ZigZag-decode an unsigned 64-bit value back to a signed value. + * @param value ZigZag-encoded value, as produced by cwist_pb_zigzag_encode(). + * @return The original signed value. + */ int64_t cwist_pb_zigzag_decode(uint64_t value) { return (int64_t)((value >> 1) ^ (uint64_t)-((int64_t)value & 1)); } diff --git a/src/net/http/async_server.c b/src/net/http/async_server.c index 93c7b376d..56444576f 100644 --- a/src/net/http/async_server.c +++ b/src/net/http/async_server.c @@ -21,10 +21,38 @@ static cwist_reactor_t *g_reactor = NULL; +/** + * @brief Decide whether the app is fully configured for HTTPS serving. + * + * Checks that the app pointer is non-NULL and that SSL mode, an SSL context, + * and an HTTPS request handler are all present. No side effects; read-only. + * + * @param app Application object, may be NULL. + * @return true if HTTPS dispatch can be used for accepted connections. + */ static bool app_use_https(const cwist_app *app) { return app && app->use_ssl && app->ssl_ctx && app->https_request_handler; } +/** + * @brief Reactor callback: accept pending connections on a listening socket. + * + * Accepts all currently queued connections in a loop (non-blocking), disables + * Nagle (and enables TCP_QUICKACK on Linux where available) on each accepted + * socket, then hands it either to the HTTPS dispatcher or to the HTTP + * connection pool, depending on the app configuration. Connections accepted + * while SSL is requested but not fully configured are closed with a warning. + * + * After the accept loop drains, the listening socket is re-armed so the worker + * keeps accepting: transient reactor re-add failures (e.g. a full io_uring SQ) + * are retried up to 1000 times with a 10 ms backoff before giving up with a + * fatal message; if the process is shutting down (@c g_cwist_running cleared), + * the reactor is stopped instead. Runs on a reactor worker thread; the + * accepted sockets are owned by the HTTP/HTTPS pools once dispatched. + * + * @param fd Listening socket fd that became readable. + * @param ctx Reactor slot payload holding the `cwist_app *` pointer. + */ static void async_accept_cb(int fd, void *ctx) { /* ctx is the reactor slot's inline payload holding the app pointer. */ cwist_app *app = *(cwist_app **)ctx; @@ -79,6 +107,25 @@ static void async_accept_cb(int fd, void *ctx) { } } +/** + * @brief Run the async (reactor-based) HTTP/HTTPS server loop for C1M scale. + * + * Initializes the HTTPS thread pool when the app serves HTTPS, otherwise the + * HTTP thread pool (without re-reading CWIST_C1M_MODE, which + * cwist_app_listen_ex() may have overridden). Sets the server socket + * non-blocking, enables SO_REUSEPORT when available, creates the reactor, and + * registers the accept callback on @p server_fd. Blocks in cwist_reactor_run() + * until the reactor is stopped (e.g. on shutdown), then tears down the reactor + * and the thread pool that was initialized. + * + * @param server_fd Listening socket to serve; set non-blocking on entry. + * @param app Application object; NULL or partially configured apps fall + * back to plain HTTP. + * @return cwist_error_t with @c err_i16 set to 0 on orderly shutdown, or -1 if + * setup failed (pool init, fcntl, or reactor creation); on the -1 path + * any initialized resources are cleaned up first and a message is + * printed to stderr. + */ cwist_error_t cwist_async_server_loop(int server_fd, cwist_app *app) { cwist_error_t err; memset(&err, 0, sizeof(err)); diff --git a/src/net/http/curl_global.c b/src/net/http/curl_global.c index 75919a826..8b23abe03 100644 --- a/src/net/http/curl_global.c +++ b/src/net/http/curl_global.c @@ -8,6 +8,14 @@ static pthread_mutex_t g_curl_global_mtx = PTHREAD_MUTEX_INITIALIZER; static int g_curl_global_ref = 0; +/** + * @brief Acquire a reference on the global libcurl instance. + * + * Initializes libcurl via curl_global_init() on the first acquisition + * (reference count transitions from 0 to 1). Thread-safe; subsequent + * calls only increment the reference count. Each call must be paired + * with exactly one cwist_curl_global_release(). + */ void cwist_curl_global_acquire(void) { pthread_mutex_lock(&g_curl_global_mtx); if (g_curl_global_ref == 0) { @@ -17,6 +25,14 @@ void cwist_curl_global_acquire(void) { pthread_mutex_unlock(&g_curl_global_mtx); } +/** + * @brief Release a reference on the global libcurl instance. + * + * Decrements the reference count and calls curl_global_cleanup() when the + * last reference is dropped. Thread-safe. Calls made without a matching + * cwist_curl_global_acquire() are ignored (the reference count never goes + * below zero). + */ void cwist_curl_global_release(void) { pthread_mutex_lock(&g_curl_global_mtx); if (g_curl_global_ref > 0) { diff --git a/src/net/http/html_response.c b/src/net/http/html_response.c index 4beb845b8..187093e9e 100644 --- a/src/net/http/html_response.c +++ b/src/net/http/html_response.c @@ -8,16 +8,38 @@ #include #include +/** + * @brief Look up a request header value by name. + * @param req Request to inspect; may be NULL. + * @param name Header name to look up. + * @return Pointer to the header value, or NULL if the request is NULL or the + * header is not present. The pointer is owned by the request. + */ static const char *request_header(const cwist_http_request *req, const char *name) { if (!req) return NULL; return cwist_http_header_get(req->headers, name); } +/** + * @brief Check whether a request header is present with the value "true". + * @param req Request to inspect; may be NULL. + * @param name Header name to check. + * @return true if the header exists and equals "true" (case-insensitive), + * false otherwise. + */ static bool header_is_true(const cwist_http_request *req, const char *name) { const char *value = request_header(req, name); return value && strcasecmp(value, "true") == 0; } +/** + * @brief Determine whether the client requested an HTML fragment only. + * + * A fragment is requested when the htmx `HX-Request` header is "true" (and it + * is not a history restore) or when a non-empty `Turbo-Frame` header is set. + * @param req Request to inspect; may be NULL. + * @return true if only a fragment of the page should be returned. + */ bool cwist_http_request_wants_fragment(const cwist_http_request *req) { if (header_is_true(req, "HX-Request")) return !header_is_true(req, "HX-History-Restore-Request"); @@ -25,6 +47,13 @@ bool cwist_http_request_wants_fragment(const cwist_http_request *req) { return frame && *frame; } +/** + * @brief Get the identifier of the fragment region the client wants updated. + * @param req Request to inspect; may be NULL. + * @return The `Turbo-Frame` id when present, otherwise the `HX-Target` header + * when present, or NULL when the client did not request a fragment or + * no target was given. The pointer is owned by the request. + */ const char *cwist_http_request_fragment_target(const cwist_http_request *req) { if (!cwist_http_request_wants_fragment(req)) return NULL; const char *frame = request_header(req, "Turbo-Frame"); @@ -33,10 +62,19 @@ const char *cwist_http_request_fragment_target(const cwist_http_request *req) { return target && *target ? target : NULL; } +/** + * @brief Check whether the response body can be set to HTML directly. + * @return true if the response has an in-memory body (not a pointer body, file + * stream, or stream mode). + */ static bool response_accepts_html(const cwist_http_response *res) { return res && res->body && !res->is_ptr_body && !res->use_file_stream && !res->stream_mode; } +/** + * @brief Replace any existing header of the same name with a new value. + * @return 0 on success, -1 if adding the header failed. + */ static int set_header(cwist_http_response *res, const char *name, const char *value) { cwist_http_header_remove(&res->headers, name); cwist_error_t err = cwist_http_header_add(&res->headers, name, value); @@ -45,6 +83,10 @@ static int set_header(cwist_http_response *res, const char *name, const char *va return ok ? 0 : -1; } +/** + * @brief Append a header without removing existing entries of the same name. + * @return 0 on success, -1 if adding the header failed. + */ static int add_header(cwist_http_response *res, const char *name, const char *value) { cwist_error_t err = cwist_http_header_add(&res->headers, name, value); bool ok = cwist_error_is_ok(&err); @@ -54,6 +96,11 @@ static int add_header(cwist_http_response *res, const char *name, const char *va /** * @brief Render `root` (consumed) to a new string, optionally behind a doctype. + * @param root Element to render; ownership is always transferred and the + * element is destroyed, even on failure. + * @param document When true, prepend "" to the rendered HTML. + * @return Newly allocated string on success (caller owns it), or NULL on + * allocation or render failure. */ static cwist_sstring *render_consumed(cwist_html_element_t *root, bool document) { cwist_sstring *html = cwist_html_render(root); @@ -80,6 +127,17 @@ static cwist_sstring *render_consumed(cwist_html_element_t *root, bool document) return out; } +/** + * @brief Set the response body to the rendered HTML of `root`. + * + * Always takes ownership of `root` and destroys it. On success the + * `Content-Type` header is set to `text/html; charset=utf-8`. + * @param res Response whose body is replaced. + * @param root Element tree to render; consumed regardless of the result. + * @param document When true, render as a full document with a doctype. + * @return 0 on success, -1 if the response cannot hold an HTML body, the + * element is NULL, or rendering/assignment fails. + */ int cwist_http_response_set_html(cwist_http_response *res, cwist_html_element_t *root, bool document) { if (!root) return -1; @@ -101,6 +159,22 @@ int cwist_http_response_set_html(cwist_http_response *res, cwist_html_element_t return set_header(res, "Content-Type", "text/html; charset=utf-8"); } +/** + * @brief Set the response body from `content`, wrapping it in `layout` for full pages. + * + * When the client requested a fragment, `content` is rendered alone; otherwise + * it is embedded into the layout component (or rendered as a full document + * when no layout is given). Takes ownership of `content` in all cases. On + * success a `Vary` header for fragment-negotiation is appended. + * @param req Request used to decide between fragment and full-page rendering. + * @param res Response whose body is replaced. + * @param content Element tree for the page content; consumed regardless of + * the result. + * @param layout Optional layout component that wraps `content`; may be NULL. + * @param layout_props Props passed to the layout component. + * @return 0 on success, -1 if the response cannot hold HTML, `content` is + * NULL, or rendering fails. + */ int cwist_http_response_set_view(const cwist_http_request *req, cwist_http_response *res, cwist_html_element_t *content, cwist_html_component_t *layout, const void *layout_props) { @@ -125,11 +199,30 @@ int cwist_http_response_set_view(const cwist_http_request *req, cwist_http_respo return add_header(res, "Vary", CWIST_HTML_FRAGMENT_VARY); } +/** + * @brief Check whether the element has a non-empty string "id" attribute. + * @return true if the element carries a non-empty "id" attribute. + */ static bool element_has_id(const cwist_html_element_t *el) { const cJSON *id = el->attributes ? cJSON_GetObjectItem(el->attributes, "id") : NULL; return cJSON_IsString(id) && id->valuestring && *id->valuestring; } +/** + * @brief Append an out-of-band swapped element to the response body. + * + * The element must have an "id" attribute. When the client requested a full + * page the element is dropped (a full page already contains the region) and + * success is reported. Otherwise an `hx-swap-oob="true"` attribute is added if + * missing and the element is rendered and appended to the body. Takes + * ownership of `el` in all cases. + * @param req Request used to decide whether an OOB update applies. + * @param res Response whose body the rendered element is appended to. + * @param el Element to append; consumed regardless of the result. + * @return 0 on success (including the dropped full-page case), -1 if the + * response cannot hold HTML, the element has no id, or rendering or + * appending fails. + */ int cwist_http_response_add_oob(const cwist_http_request *req, cwist_http_response *res, cwist_html_element_t *el) { if (!el) return -1; @@ -160,6 +253,11 @@ int cwist_http_response_add_oob(const cwist_http_request *req, cwist_http_respon return ok ? 0 : -1; } +/** + * @brief Check whether a redirect location is safe to send in a header. + * @return true if the location is non-empty and contains no control bytes + * (values below 0x20 or 0x7f). + */ static bool location_is_valid(const char *location) { if (!location || !*location) return false; for (const unsigned char *p = (const unsigned char *)location; *p; p++) { @@ -168,6 +266,19 @@ static bool location_is_valid(const char *location) { return true; } +/** + * @brief Redirect an HTML request to `location`, using htmx headers when applicable. + * + * For htmx requests (`HX-Request: true`) the status stays 200 and the target + * is sent via the `HX-Redirect` header; otherwise a 303 See Other response + * with a `Location` header is produced. Any previous `Location` and + * `HX-Redirect` headers are removed and the body is cleared. + * @param req Request used to decide between htmx and regular redirects. + * @param res Response to modify. + * @param location Redirect target; must be non-empty and free of control bytes. + * @return 0 on success, -1 if the response cannot hold HTML, the location is + * invalid, or header/body updates fail. + */ int cwist_http_response_html_redirect(const cwist_http_request *req, cwist_http_response *res, const char *location) { if (!response_accepts_html(res) || !location_is_valid(location)) return -1; diff --git a/src/net/http/http.c b/src/net/http/http.c index 6ea7e63e1..038d1215b 100644 --- a/src/net/http/http.c +++ b/src/net/http/http.c @@ -77,6 +77,14 @@ #include #endif +/** + * @brief Return the number of CPUs available to this process. + * + * Honors the process CPU affinity mask where the platform supports it + * (sched_getaffinity on Linux); falls back to sysconf(3)/sysctl and + * ultimately 1 when the count cannot be determined. + * @return Number of usable CPUs, at least 1. + */ long get_cpu_cores(void) { #if defined(_WIN32) || defined(_WIN64) SYSTEM_INFO sysinfo; @@ -119,10 +127,24 @@ long get_cpu_cores(void) { static unsigned int g_http_pool_core_limit = 0; +/** + * @brief Fix the HTTP pool worker count to a specific value. + * @param limit Fixed thread count; 0 restores automatic sizing. + */ void cwist_http_pool_limit_core(unsigned int limit) { g_http_pool_core_limit = limit; } +/** + * @brief Compute the HTTP worker/event-loop thread count. + * + * Resolution order: a value fixed with cwist_http_pool_limit_core(), then the + * CWIST_WORKER_THREADS environment override, then CPU-core based sizing + * modified by CWIST_WORKERS (worker-process count) and CWIST_C1M_MODE. + * C1M mode divides the core budget across worker processes (one event loop + * per CPU); classic mode multiplies it to cover blocking keep-alive handlers. + * @return Computed thread count, clamped to the mode-specific range. + */ long get_optimal_thread_count(void) { if (g_http_pool_core_limit > 0) { return (long)g_http_pool_core_limit; @@ -225,10 +247,27 @@ static _Atomic long g_http_continuation_shed = 0; static _Atomic uint64_t g_reactor_gen = 0; static _Atomic long g_reactor_posters = 0; +/** + * @brief Return the current reactor generation counter. + * + * Deferred completions capture the generation so they can detect pool + * teardown; see g_reactor_gen for the protocol. + */ uint64_t cwist_http_reactor_generation(void) { return atomic_load(&g_reactor_gen); } +/** + * @brief Post a reactor node only while the captured generation is current. + * + * Counts the caller in g_reactor_posters around the generation check and the + * post so cwist_http_pool_destroy() cannot free the reactor in between. + * @param reactor Reactor to post into. + * @param gen Generation the completion was created under. + * @param node Post node to enqueue. + * @return true when the node was posted; false when the generation moved or + * the post failed (the caller must close instead). + */ bool cwist_http_reactor_post_live(cwist_reactor_t *reactor, uint64_t gen, cwist_reactor_post_t *node) { atomic_fetch_add(&g_reactor_posters, 1); @@ -237,13 +276,13 @@ bool cwist_http_reactor_post_live(cwist_reactor_t *reactor, uint64_t gen, return posted; } -/* Continuations dropped because the reactor post queue was full; each one +/** Continuations dropped because the reactor post queue was full; each one * closed its connection. Exported for metrics/observability. */ long cwist_http_continuation_shed_count(void) { return atomic_load_explicit(&g_http_continuation_shed, memory_order_relaxed); } -/* Live C1M connections (accepted, not yet closed). The shutdown drain in +/** Live C1M connections (accepted, not yet closed). The shutdown drain in * cwist_app_listen_ex polls this to exit early once nothing is left. */ long cwist_http_inflight_count(void) { return atomic_load_explicit(&g_http_inflight, memory_order_relaxed); @@ -251,6 +290,13 @@ long cwist_http_inflight_count(void) { #define CWIST_HTTP_INFLIGHT_PER_THREAD 32 #define CWIST_HTTP_INFLIGHT_FD_RESERVE 4096 +/** + * @brief Compute the per-process cap on concurrently served connections. + * + * Base is the thread count times CWIST_HTTP_INFLIGHT_PER_THREAD; on POSIX + * systems the RLIMIT_NOFILE budget minus a reserve raises it when higher. + * @return Inflight connection limit (always >= the thread-count floor). + */ static long cwist_http_inflight_limit(void) { long base = g_http_thread_count > 0 ? g_http_thread_count : get_optimal_thread_count(); long floor = base * CWIST_HTTP_INFLIGHT_PER_THREAD; @@ -272,6 +318,11 @@ static const char CWIST_HTTP_503[] = static _Thread_local http_thread_worker_t *t_current_worker = NULL; +/** + * @brief Reactor worker main loop for the C1M pool. + * @param arg http_thread_worker_t for this thread. + * @return NULL when the reactor stops. + */ static void *http_pool_worker(void *arg) { http_thread_worker_t *w = (http_thread_worker_t *)arg; t_current_worker = w; @@ -313,6 +364,13 @@ static http_dynamic_pool_t g_dyn_pool; * cwist_http_pool_init. */ static void http_rx_recv_cb(void *conn_ptr, int res); +/** + * @brief Main loop of a classic-mode dynamic pool worker thread. + * + * Pops tasks from the shared queue, runs their handler, and exits after an + * idle timeout when the pool is above its base size (scale-down). + * @return NULL when the pool stops or the worker scales down. + */ static void *http_dynamic_worker_thread(void *arg) { (void)arg; while (atomic_load_explicit(&g_dyn_pool.running, memory_order_acquire)) { @@ -393,6 +451,14 @@ static void *http_dynamic_worker_thread(void *arg) { return NULL; } +/** + * @brief Spawn one detached dynamic-pool worker thread. + * + * Uses a reduced stack size (CWIST_POOL_STACK_SIZE), retrying with the + * default stack when the pthread implementation rejects it. + * @return true when the worker was created; false otherwise (the + * active-worker count is rolled back). + */ static bool http_spawn_worker(void) { pthread_attr_t attr; pthread_attr_init(&attr); @@ -424,6 +490,10 @@ static bool http_spawn_worker(void) { return true; } +/** + * @brief Initialize the HTTP connection pool using CWIST_C1M_MODE. + * @return 0 on success, -1 on failure. + */ int cwist_http_pool_init(void) { const char *c1m = getenv("CWIST_C1M_MODE"); bool use_c1m = true; @@ -435,6 +505,13 @@ int cwist_http_pool_init(void) { return cwist_http_pool_init_mode(use_c1m); } +/** + * @brief Initialize the HTTP connection pool in the requested mode. + * @param use_c1m true for the event-driven reactor (C1M) pool, false for + * the classic dynamic thread pool. + * @return 0 on success, -1 on failure (partially created state is cleaned up + * by cwist_http_pool_destroy()). + */ int cwist_http_pool_init_mode(bool use_c1m) { atomic_store(&g_http_pool_stopping, false); g_http_thread_count = get_optimal_thread_count(); @@ -500,6 +577,12 @@ int cwist_http_pool_init_mode(bool use_c1m) { return 0; } +/** + * @brief Queue a connection for handling on the classic dynamic pool. + * + * Enforces the inflight limit (over-limit connections get a bare 503 and are + * closed) and spawns an extra worker when queued demand exceeds idle capacity. + */ void cwist_http_pool_submit(int client_fd, void (*handler)(int, void *), void *ctx) { long limit = cwist_http_inflight_limit(); long inflight = atomic_fetch_add_explicit(&g_http_inflight, 1, memory_order_acq_rel) + 1; @@ -548,12 +631,22 @@ void cwist_http_pool_submit(int client_fd, void (*handler)(int, void *), void *c pthread_mutex_unlock(&g_dyn_pool.lock); } +/** + * @brief Requeue the current connection's next handler turn on the pool. + * @return false for invalid arguments (fd < 0 or NULL handler). + */ bool cwist_http_pool_rearm_current(int client_fd, void (*handler)(int, void *), void *ctx) { if (client_fd < 0 || !handler) return false; cwist_http_pool_submit(client_fd, handler, ctx); return true; } +/** + * @brief Tear down both pool variants. + * + * Stops new work, drains queued tasks, retires the reactor generation and + * waits for in-flight posters, then stops, joins, and frees the C1M reactors. + */ void cwist_http_pool_destroy(void) { /* Queued continuations must release, not repost into a dying reactor. */ atomic_store(&g_http_pool_stopping, true); @@ -623,6 +716,11 @@ typedef struct { static void http_async_event_cb(int fd, void *ctx); static bool http_async_stash_grow(cwist_http_async_conn_t *conn, size_t need); +/** + * @brief Keep-alive idle timeout in seconds. + * @return The validated CWIST_HTTP_KEEP_ALIVE_TIMEOUT env override or the + * CWIST_HTTP_KEEP_ALIVE_TIMEOUT_SEC default; cached after first use. + */ static uint32_t cwist_http_keep_alive_timeout_sec(void) { static int cached_timeout = -1; if (cached_timeout < 0) { @@ -638,6 +736,12 @@ static uint32_t cwist_http_keep_alive_timeout_sec(void) { return (uint32_t)cached_timeout; } +/** + * @brief Release an async connection shell and its stashes. + * + * Decrements the worker load slot and the global inflight count, and frees + * rbuf, obuf, and the shell itself. The fd is NOT closed here. + */ static void http_async_conn_release(cwist_http_async_conn_t *conn) { if (!conn) return; if (g_worker_loads && conn->worker_id < (uint32_t)g_http_thread_count) { @@ -659,7 +763,7 @@ static void http_async_conn_release(cwist_http_async_conn_t *conn) { * the RECV-first arm stays available. */ #define CWIST_ASYNC_STASH_IDLE 4096 -/* Arm the connection's next receive wait. RX-uring first when the reactor +/** Arm the connection's next receive wait. RX-uring first when the reactor * has a real io_uring ring: one RECV SQE into rbuf + len whose completion * drives the state machine, replacing the one-shot POLL + recv() pair. * Falls back to the legacy one-shot POLL on unsupported reactors, low stash @@ -708,7 +812,7 @@ static bool http_async_arm_wait(int fd, cwist_http_async_conn_t *conn, return cwist_reactor_add(reactor, fd, http_async_event_cb, &next, sizeof(next)); } -/* Run the connection handler and act on its verdict. Shared by the POLL +/** Run the connection handler and act on its verdict. Shared by the POLL * entry (http_async_event_cb) and the RX-uring RECV completion entry * (http_rx_recv_cb). Takes over fd/conn ownership in every outcome. */ static void http_async_dispatch(int fd, cwist_http_async_conn_t *conn, @@ -773,7 +877,7 @@ static void http_async_dispatch(int fd, cwist_http_async_conn_t *conn, } } -/* RX-uring RECV completion entry (see reactor_rx.h for the SQE namespace). +/** RX-uring RECV completion entry (see reactor_rx.h for the SQE namespace). * The completion IS the readiness signal: positive res staged bytes into the * stash, -EAGAIN means the POLL fallback takes over the wait, 0 is EOF, and * any other negative res is a recv error. Runs on the reactor owner thread @@ -869,6 +973,13 @@ static void http_rx_recv_cb(void *conn_ptr, int res) { * have the fd armed); not part of the public API. Defined below. */ static void http_async_close(int client_fd, cwist_http_async_conn_t *conn); +/** Reactor one-shot callback for an async connection. + * + * Reaps keep-alive connections past the idle timeout (draining first so the + * teardown is a graceful FIN, not an RST), prefers an RX-uring RECV SQE over + * POLL when the ring is free, and otherwise dispatches the connection handler + * via http_async_dispatch(). + */ static void http_async_event_cb(int fd, void *ctx) { http_async_ctx_t *c = (http_async_ctx_t *)ctx; cwist_http_async_conn_t *conn = c->conn; @@ -908,6 +1019,12 @@ typedef struct { http_async_ctx_t next; } http_async_continuation_t; +/** + * @brief Reactor-posted continuation: resume a pipelined async connection. + * + * Runs on the target reactor thread; closes instead of serving once the app + * or the pool is stopping. + */ static void http_async_continue(void *ctx) { http_async_continuation_t *continuation = ctx; http_async_ctx_t next = continuation->next; @@ -919,6 +1036,14 @@ static void http_async_continue(void *ctx) { http_async_event_cb(next.client_fd, &next); } +/** + * @brief Re-arm an async connection for its next event. + * + * Handles deferred-completion shutdown races, peer EOF, pipelined bytes + * (posted as a continuation so other connections run between batches), and + * RECV-first re-arm with a POLL fallback. + * @return false when the connection was closed or the re-arm failed. + */ bool cwist_http_async_rearm(int client_fd, cwist_reactor_t *reactor, cwist_http_async_conn_t *conn) { if (client_fd < 0 || !reactor || !conn) return false; @@ -984,6 +1109,11 @@ bool cwist_http_async_rearm(int client_fd, cwist_reactor_t *reactor, return true; } +/** Close an async connection: drain pending receive-queue bytes (so the + * kernel answers with FIN instead of RST, mirroring + * https_connection_teardown()), close the fd, and release the connection + * shell. Must run on the connection's reactor owner thread. + */ static void http_async_close(int client_fd, cwist_http_async_conn_t *conn) { if (client_fd >= 0) { /* close() on a socket with unread receive-queue data makes the @@ -1006,6 +1136,12 @@ static void http_async_close(int client_fd, cwist_http_async_conn_t *conn) { http_async_conn_release(conn); } +/** + * @brief Close an async connection whose owning worker pool is gone. + * + * Marks the load slot index invalid so the teardown does not touch load + * counters that may belong to a later pool generation. + */ void cwist_http_async_close_orphan(int client_fd, cwist_http_async_conn_t *conn) { /* The worker that owned conn is gone, and its load slot index may belong * to a later pool by now: leave the load counters alone. */ @@ -1013,6 +1149,14 @@ void cwist_http_async_close_orphan(int client_fd, cwist_http_async_conn_t *conn) http_async_close(client_fd, conn); } +/** + * @brief Submit a connection to the C1M async reactor pool. + * + * Enforces the inflight limit (503 + close on overflow), sets the socket + * non-blocking, allocates the connection shell, and load-balances with + * power-of-two-choices over the per-worker load counters. + * @return false when the connection was rejected or the initial arm failed. + */ bool cwist_http_pool_submit_async(int client_fd, cwist_async_handler_t handler, void *ctx) { long limit = cwist_http_inflight_limit(); long inflight = atomic_fetch_add_explicit(&g_http_inflight, 1, memory_order_acq_rel) + 1; @@ -1120,6 +1264,13 @@ const char *cwist_http_method_to_string(cwist_http_method_t method) { #define MAGIC_DELE MAKE_MAGIC4('D', 'E', 'L', 'E') #define MAGIC_HEAD MAKE_MAGIC4('H', 'E', 'A', 'D') +/** + * @brief Parse a method token (with explicit length) into a method enum. + * + * Uses a SWAR 4-byte magic fast path for GET/POST/PUT/DELETE/HEAD, then + * exact-length comparisons. + * @return Matching method, or CWIST_HTTP_UNKNOWN. + */ cwist_http_method_t cwist_http_string_to_method_len(const char *str, size_t len) { if (!str || len == 0) return CWIST_HTTP_UNKNOWN; @@ -1161,6 +1312,10 @@ cwist_http_method_t cwist_http_string_to_method_len(const char *str, size_t len) return CWIST_HTTP_UNKNOWN; } +/** + * @brief Parse a NUL-terminated method string into a method enum. + * @return Matching method, or CWIST_HTTP_UNKNOWN for NULL/unrecognized input. + */ cwist_http_method_t cwist_http_string_to_method(const char *method_str) { if (!method_str) return CWIST_HTTP_UNKNOWN; return cwist_http_string_to_method_len(method_str, strlen(method_str)); @@ -1667,6 +1822,11 @@ time_t cwist_http_parse_date(const char *str) { return timegm(&tm); } +/** + * @brief Resolve the peer IP address for a connected client socket. + * @param fd Connected client socket descriptor. + * @return New sstring with the textual IP; "127.0.0.1" when unavailable. + */ cwist_sstring *cwist_get_client_ip_from_fd(int fd) { cwist_sstring *s = cwist_sstring_create(); cwist_sstring_assign(s, "127.0.0.1"); @@ -1766,6 +1926,12 @@ static void cwist_http_response_release_ptr_body(cwist_http_response *res) { * @brief Allocate and initialize a default HTTP response object. * @return Newly allocated response, or NULL on allocation failure. */ +/** + * @brief Shared allocator for cwist_http_response_create{,_in_arena}. + * @param arena Arena to carve from (NULL allocates from the heap). + * @param arena_borrowed true when the arena is owned by the caller. + * @return Newly initialized response, or NULL on allocation failure. + */ static cwist_http_response *cwist_http_response_create_impl(cwist_arena_t *arena, bool arena_borrowed) { cwist_http_response *res = @@ -1807,6 +1973,10 @@ static cwist_http_response *cwist_http_response_create_impl(cwist_arena_t *arena return res; } +/** + * @brief Allocate a response backed by its own arena. + * @return Newly allocated response, or NULL on failure. + */ cwist_http_response *cwist_http_response_create(void) { cwist_arena_t *arena = cwist_arena_create(0); cwist_http_response *res = cwist_http_response_create_impl(arena, false); @@ -1817,6 +1987,11 @@ cwist_http_response *cwist_http_response_create(void) { return res; } +/** + * @brief Allocate a response carved from a caller-owned arena. + * @param arena Arena to allocate from; NULL falls back to + * cwist_http_response_create(). + */ cwist_http_response *cwist_http_response_create_in_arena(void *arena) { if (!arena) return cwist_http_response_create(); return cwist_http_response_create_impl((cwist_arena_t *)arena, true); @@ -2136,6 +2311,11 @@ size_t cwist_http_serialize_headers(cwist_http_response *res, char *buf, size_t #if defined(__linux__) && defined(TCP_CORK) #define CWIST_TCP_CORK_DEFAULT_BURST (256 * 1024) +/** + * @brief Runtime toggle for the TCP_CORK coalescing layer (Linux only). + * @return true when CWIST_USE_TCP_CORK is set to a positive value; always + * false on other platforms. + */ bool cwist_tcp_cork_enabled(void) { static int enabled = -1; /* benign idempotent race on first use */ if (enabled < 0) { @@ -2151,6 +2331,10 @@ bool cwist_tcp_cork_enabled(void) { return enabled == 1; } +/** + * @brief TCP_CORK flush burst size in bytes. + * @return The CWIST_TCP_CORK_BURST env value (minimum 16 KiB) or the default. + */ static size_t cwist_tcp_cork_burst(void) { static size_t burst = 0; if (burst == 0) { @@ -2166,17 +2350,25 @@ static size_t cwist_tcp_cork_burst(void) { return burst; } +/** + * @brief Set or clear TCP_CORK on a socket. + * @return setsockopt(2) result (0 on success). + */ static int cwist_tcp_cork_set(int fd, bool on) { int v = on ? 1 : 0; return setsockopt(fd, IPPROTO_TCP, TCP_CORK, &v, sizeof(v)); } -/* Flush pending corked bytes, then re-cork: a burst boundary. */ +/** Flush pending corked bytes, then re-cork: a burst boundary. */ static void cwist_tcp_cork_flush(int fd) { cwist_tcp_cork_set(fd, false); cwist_tcp_cork_set(fd, true); } #else +/** + * @brief TCP_CORK coalescing stub for platforms without TCP_CORK. + * @return Always false. + */ bool cwist_tcp_cork_enabled(void) { return false; } @@ -2523,6 +2715,9 @@ typedef struct { _Static_assert(sizeof(http_parked_write_t) <= CWIST_REACTOR_PAYLOAD_SIZE, "parked write state must fit a reactor slot payload"); +/** + * @brief Absolute write deadline (monotonic seconds) for a parked write. + */ static uint32_t http_parked_write_deadline(void) { return cwist_fast_monotonic_sec() + cwist_http_keep_alive_timeout_sec(); } @@ -2531,11 +2726,22 @@ static void http_parked_write_cb(int fd, void *ctx); /* Re-park on a fresh one-shot POLLOUT slot; the slot copies the payload and * ownership of buf/state moves with it. False means the caller must finish. */ +/** + * @brief Re-park a deferred write on a fresh one-shot POLLOUT slot. + * @return false past the deadline or when the slot could not be armed; the + * caller must finish the write path itself. + */ static bool http_parked_rearm(int fd, http_parked_write_t *w) { return cwist_fast_monotonic_sec() <= w->deadline_sec && cwist_reactor_add_out(w->reactor, fd, http_parked_write_cb, w, sizeof(*w)); } +/** + * @brief Finish a parked write: release buffers and re-arm or close. + * + * Re-arms keep-alive only when the body drained fully and the app is still + * running; otherwise closes the connection. + */ static void http_parked_write_finish(int fd, http_parked_write_t *w, bool drained) { cwist_reactor_t *reactor = w->reactor; cwist_http_async_conn_t *conn = w->conn; @@ -2558,6 +2764,14 @@ static void http_parked_write_finish(int fd, http_parked_write_t *w, bool draine } } +/** + * @brief POLLOUT callback draining a parked (deferred) response write. + * + * Sends the buffered remainder, the sendfile body (FILE mode), or the + * referenced pointer body (BODYREF mode); re-parks on EAGAIN until the + * deadline. Frees the park state and re-arms or closes via + * http_parked_write_finish(). + */ static void http_parked_write_cb(int fd, void *ctx) { http_parked_write_t *w = (http_parked_write_t *)ctx; int flags = 0; @@ -2875,6 +3089,15 @@ static cwist_file_begin_result_t cwist_http_file_begin(int client_fd, cwist_http #endif } +/** + * @brief Send a response on the async path, parking the unsent remainder on + * EAGAIN instead of blocking the reactor thread. + * + * Pointer bodies park by reference (ownership moves to the park state); + * other bodies deep-copy the unsent remainder. File streams use the + * non-blocking sendfile path. On completion the connection is re-armed for + * keep-alive or closed. + */ void cwist_http_async_send_response(int client_fd, cwist_http_response *res, cwist_reactor_t *reactor, cwist_http_async_conn_t *conn, bool keep_alive, bool head_only) { @@ -2991,6 +3214,11 @@ void cwist_http_async_send_response(int client_fd, cwist_http_response *res, } } +/** + * @brief Async send variant that reports the outcome to the caller instead of + * re-arming or closing the connection itself (deferred-completion handoff). + * @return KEEPALIVE / DEFERRED / CLOSE describing the connection's fate. + */ cwist_async_send_status_t cwist_http_send_response_async(int client_fd, cwist_http_response *res, cwist_http_async_conn_t *conn, bool keep_alive, bool head_only) { @@ -3105,6 +3333,11 @@ cwist_async_send_status_t cwist_http_send_response_async(int client_fd, cwist_ht * through the bounded poll wait of cwist_http_sendmsg_all instead, keeping * byte order without unbounded buffering. */ +/** + * @brief Append bytes to a connection's coalesced-output stash. + * @return 0 on success, -1 when the data would exceed the coalesce cap or + * the stash could not be grown. + */ int cwist_http_coalesce_append(cwist_http_async_conn_t *conn, const void *data, size_t len) { if (len == 0) return 0; if (conn->olen + len > CWIST_HTTP_COALESCE_MAX) return -1; @@ -3124,6 +3357,13 @@ int cwist_http_coalesce_append(cwist_http_async_conn_t *conn, const void *data, return 0; } +/** + * @brief Flush the coalesced-output stash with one speculative write. + * + * On a partial write the unsent remainder is deep-copied into the parked + * writer, which re-arms or closes the connection when it drains. + * @return DONE when fully written, PARKED when deferred, ERROR otherwise. + */ cwist_coalesce_flush_status_t cwist_http_coalesce_flush(int client_fd, cwist_http_async_conn_t *conn, bool keep_alive) { if (!conn || conn->olen == 0) return CWIST_COALESCE_FLUSH_DONE; @@ -3168,6 +3408,10 @@ cwist_http_coalesce_flush(int client_fd, cwist_http_async_conn_t *conn, bool kee return CWIST_COALESCE_FLUSH_ERROR; } +/** + * @brief Flush the coalesced-output stash, blocking in poll() until drained. + * @return 0 on success, -1 on send failure. + */ int cwist_http_coalesce_flush_blocking(int client_fd, cwist_http_async_conn_t *conn) { if (!conn || conn->olen == 0) return 0; struct iovec iov = {.iov_base = conn->obuf, .iov_len = conn->olen}; @@ -3183,6 +3427,10 @@ int cwist_http_coalesce_flush_blocking(int client_fd, cwist_http_async_conn_t *c return rc; } +/** + * @brief Build a minimal text error response into the coalesce stash. + * @return 0 on success, -1 when the response could not be appended. + */ int cwist_http_coalesce_error_response(cwist_http_async_conn_t *conn, int status) { const char *reason = cwist_http_status_reason(status); if (!reason) reason = "Error"; @@ -3197,6 +3445,15 @@ int cwist_http_coalesce_error_response(cwist_http_async_conn_t *conn, int status return cwist_http_coalesce_append(conn, buf, len); } +/** + * @brief Coalescing variant of the async send: serialize small responses + * directly into the connection's output stash so one reactor turn flushes + * many responses with a single write. + * + * Oversized responses, cap pressure, and file streams drain through the + * bounded-blocking or parked paths instead of unbounded buffering. + * @return KEEPALIVE / DEFERRED / CLOSE describing the connection's fate. + */ cwist_async_send_status_t cwist_http_send_response_coalesced(int client_fd, cwist_http_response *res, cwist_http_async_conn_t *conn, @@ -3380,6 +3637,10 @@ cwist_async_send_status_t cwist_http_send_response_coalesced(int client_fd, return keep_alive ? CWIST_ASYNC_SEND_KEEPALIVE : CWIST_ASYNC_SEND_CLOSE; } +/** + * @brief Map an HTTP status code to its RFC reason phrase. + * @return Static reason phrase, or NULL for unknown codes. + */ const char *cwist_http_status_reason(int status) { switch (status) { case 100: return "Continue"; @@ -3815,6 +4076,10 @@ cwist_http_parse_request_with_header_end(const char *raw_request, size_t raw_len return req; } +/** + * @brief Parse a NUL-terminated raw HTTP request buffer. + * @return Parsed request, or NULL when no header terminator exists. + */ cwist_http_request *cwist_http_parse_request(const char *raw_request) { if (!raw_request) return NULL; size_t raw_len = strlen(raw_request); @@ -3823,6 +4088,10 @@ cwist_http_request *cwist_http_parse_request(const char *raw_request) { return cwist_http_parse_request_with_header_end(raw_request, raw_len, header_end, NULL); } +/** + * @brief Parse a raw HTTP request buffer with an explicit length. + * @return Parsed request, or NULL when no header terminator exists. + */ cwist_http_request *cwist_http_parse_request_len(const char *buf, size_t len) { if (!buf || len == 0) return NULL; const char *header_end = cwist_simd_find_crlfcrlf(buf, len); @@ -3830,6 +4099,14 @@ cwist_http_request *cwist_http_parse_request_len(const char *buf, size_t len) { return cwist_http_parse_request_with_header_end(buf, len, header_end, NULL); } +/** + * @brief Serialize a full response (headers + body) into a heap buffer. + * + * Streaming responses are auto-finalized so the chunked terminator is + * present even when the handler skipped cwist_http_response_stream_end(). + * @return 0 with @p out and @p out_len set, or -1 (invalid input, file-stream + * body, or allocation failure). + */ int cwist_http_response_serialize(cwist_http_response *res, char **out, size_t *out_len) { if (!res || !out || !out_len) return -1; if (res->use_file_stream) return -1; /* streaming bodies need a socket */ @@ -3865,7 +4142,7 @@ int cwist_http_response_serialize(cwist_http_response *res, char **out, size_t * /* --- Streaming producer (issue #201 Phase 1) ------------------------------ */ -/* Push everything stream_buf holds past stream_flushed to the live sink, +/** Push everything stream_buf holds past stream_flushed to the live sink, * serializing the head first (with Transfer-Encoding: chunked) on the first * call. Returns 0 on success, -1 when the sink rejects a write. */ static int cwist_response_stream_flush(cwist_http_response *res) { @@ -3887,6 +4164,13 @@ static int cwist_response_stream_flush(cwist_http_response *res) { return 0; } +/** + * @brief Switch a response to chunked streaming mode. + * + * Discards any body assigned before the switch. Incompatible with pointer + * or file-stream bodies. + * @return 0 on success, -1 otherwise. + */ int cwist_http_response_stream_begin(cwist_http_response *res) { if (!res || res->stream_mode || res->is_ptr_body || res->use_file_stream) return -1; res->stream_mode = true; @@ -3896,6 +4180,13 @@ int cwist_http_response_stream_begin(cwist_http_response *res) { return 0; } +/** + * @brief Append one data chunk to a streaming response. + * + * Frames the chunk (hex length prefix / CRLF suffix) and flushes through the + * sink. A sink failure marks the stream failed. + * @return 0 on success, -1 on error. + */ int cwist_http_response_stream_write(cwist_http_response *res, const char *data, size_t len) { if (!res || !res->stream_mode || res->stream_ended) return -1; if (!data) return -1; @@ -3921,6 +4212,11 @@ int cwist_http_response_stream_write(cwist_http_response *res, const char *data, return 0; } +/** + * @brief Terminate a streaming response with the final zero-length chunk and + * flush. + * @return 0 on success, -1 on error. + */ int cwist_http_response_stream_end(cwist_http_response *res) { if (!res || !res->stream_mode || res->stream_ended) return -1; if (!res->stream_buf) { @@ -3977,6 +4273,13 @@ static bool http_chunk_append(cwist_sstring *out, const char *data, size_t len) return ok; } +/** + * @brief Blocking read and reassembly of a chunked transfer-encoded body. + * + * Consumes the chunked message from buf (recv/poll as needed), compacts the + * leftover bytes to the buffer head, and appends decoded chunks to @p out. + * @return 0 on success, -1 on malformed framing, overflow, or I/O error. + */ static int http_read_chunked_body(int client_fd, char *buf, size_t *avail, size_t buf_cap, cwist_sstring *out) { size_t offset = 0; @@ -4076,6 +4379,16 @@ static int http_read_chunked_body(int client_fd, char *buf, size_t *avail, size_ return 0; } +/** + * @brief Blocking read of one full request from a classic-pool connection. + * + * Bounds the header read by CWIST_HTTP_HEADERS_TIMEOUT_MS, answers a + * validated Expect: 100-continue, then completes the body per Content-Length + * or chunked framing. Leftover pipelined bytes are compacted into read_buf + * and *buf_len is updated. + * @return Parsed request, or NULL with *err_out set (defaults to + * CWIST_HTTP_PARSE_EOF). + */ cwist_http_request *cwist_http_receive_request(int client_fd, char *read_buf, size_t buf_size, size_t *buf_len, cwist_http_parse_error_t *err_out) { if (err_out) *err_out = CWIST_HTTP_PARSE_EOF; @@ -4262,7 +4575,7 @@ cwist_http_request *cwist_http_receive_request(int client_fd, char *read_buf, si * the copy is cheaper than allocating the connection a fresh stash. */ #define CWIST_HTTP_BODY_ZC_MIN (16 * 1024) -/* Grow the recv stash. Returns false when the hard cap is reached. */ +/** Grow the recv stash. Returns false when the hard cap is reached. */ static bool http_async_stash_grow(cwist_http_async_conn_t *conn, size_t need) { /* RX-uring invariant: the stash buffer must not move while a RECV SQE * references it. Growth only happens while serving (in-flight RECV @@ -4534,6 +4847,10 @@ static const cwist_mime_entry CWIST_MIME_TABLE[] = {{".html", "text/html; charse {".txt", "text/plain; charset=utf-8"}, {".ico", "image/x-icon"}}; +/** + * @brief Guess a MIME type from a filename extension. + * @return Static MIME string; "application/octet-stream" when unknown. + */ static const char *cwist_guess_mime(const char *file_path) { if (!file_path) return "application/octet-stream"; const char *dot = strrchr(file_path, '.'); diff --git a/src/net/http/http2.c b/src/net/http/http2.c index feb4bef7b..da1a0b005 100644 --- a/src/net/http/http2.c +++ b/src/net/http/http2.c @@ -94,23 +94,32 @@ #define CWIST_HTTP2_MAX_CONTINUATIONS 32 #define CWIST_HTTP2_MAX_HEADERS_PER_REQUEST 256 -/* Monotonic clock in milliseconds, for the connection idle deadline. */ +/** + * @brief Monotonic clock in milliseconds, for the connection idle deadline. + * @return Current CLOCK_MONOTONIC time in milliseconds. + */ static uint64_t h2_now_ms(void) { struct timespec ts; clock_gettime(CLOCK_MONOTONIC, &ts); return (uint64_t)ts.tv_sec * 1000 + (uint64_t)ts.tv_nsec / 1000000; } -/* Monotonic clock in microseconds, for flow-control RTT samples/pacing. */ +/** + * @brief Monotonic clock in microseconds, for flow-control RTT samples/pacing. + * @return Current CLOCK_MONOTONIC time in microseconds. + */ static uint64_t h2_now_us(void) { struct timespec ts; clock_gettime(CLOCK_MONOTONIC, &ts); return (uint64_t)ts.tv_sec * 1000000 + (uint64_t)ts.tv_nsec / 1000; } -/* Idle deadline for a connection with no complete frame arriving. Defaults - * to CWIST_HTTP2_IDLE_TIMEOUT_MS, overridable via the env var of the same - * name (read once at first use). */ +/** + * @brief Idle deadline for a connection with no complete frame arriving. + * Defaults to CWIST_HTTP2_IDLE_TIMEOUT_MS, overridable via the env var of the + * same name (read once at first use). + * @return Idle timeout in milliseconds. + */ static int h2_idle_timeout_ms(void) { static int timeout_ms = 0; if (timeout_ms == 0) { @@ -127,12 +136,14 @@ static int h2_idle_timeout_ms(void) { return timeout_ms; } -/* Grace window between sending GOAWAY on idle timeout and closing the - * connection. A client that raced a request onto the connection just as it - * went idle needs time to see the GOAWAY and retry; closing the socket the - * instant GOAWAY leaves makes strict clients (Firefox) surface a protocol - * error where lenient ones silently retry. Overridable via - * CWIST_HTTP2_GOAWAY_GRACE_MS. */ +/** + * @brief Grace window between sending GOAWAY on idle timeout and closing the + * connection. + * A client that raced a request onto the connection just as it went idle needs + * time to see the GOAWAY and retry; closing the socket the instant GOAWAY + * leaves makes strict clients (Firefox) surface a protocol error where + * lenient ones silently retry. Overridable via CWIST_HTTP2_GOAWAY_GRACE_MS. + */ #define CWIST_HTTP2_GOAWAY_GRACE_MS_DEFAULT 2000 static int h2_goaway_grace_ms(void) { static int grace_ms = -1; @@ -150,6 +161,12 @@ static int h2_goaway_grace_ms(void) { return grace_ms; } +/** + * @brief Maximum burst of RST_STREAM frames accepted per refill window. + * Defaults to CWIST_HTTP2_DEFAULT_RST_BURST, overridable via + * CWIST_HTTP2_MAX_RST_BURST (read once at first use). + * @return RST burst budget. + */ static uint32_t h2_max_rst_burst(void) { static uint32_t val = 0; if (val == 0) { @@ -166,6 +183,12 @@ static uint32_t h2_max_rst_burst(void) { return val; } +/** + * @brief RST_STREAM refill rate in credits per second. + * Defaults to CWIST_HTTP2_DEFAULT_RST_RATE, overridable via + * CWIST_HTTP2_MAX_RST_RATE (read once at first use). + * @return RST refill rate per second. + */ static uint32_t h2_max_rst_rate(void) { static uint32_t val = 0; if (val == 0) { @@ -246,6 +269,13 @@ struct cwist_h2_async_queue { _Atomic int refs; }; +/** + * @brief Destroy a deferred-response queue node and everything it owns. + * Frees the send response (when separately owned), the handler response, and + * the request, then the node itself. Thread-safe: only called when no other + * party holds the node. + * @param n Node to discard; must not be on a queue. + */ static void h2_async_node_discard(h2_async_node *n) { if (n->send_owned && n->send && n->send != n->res) cwist_http_response_destroy(n->send); if (n->res) cwist_http_response_destroy(n->res); @@ -253,6 +283,13 @@ static void h2_async_node_discard(h2_async_node *n) { cwist_free(n); } +/** + * @brief Create the deferred-response completion queue. + * Allocates the queue and its wake descriptor (eventfd on Linux, a pipe + * elsewhere), both non-blocking. The queue is born with one reference held + * by the connection. + * @return Newly allocated queue, or NULL on allocation or descriptor failure. + */ static cwist_h2_async_queue *cwist_h2_async_queue_create(void) { cwist_h2_async_queue *q = (cwist_h2_async_queue *)cwist_alloc(sizeof(*q)); if (!q) return NULL; @@ -278,11 +315,23 @@ static cwist_h2_async_queue *cwist_h2_async_queue_create(void) { return q; } +/** + * @brief Take an additional reference on the queue (for an in-flight + * cwist_async handle). + * @param q Queue to retain; NULL is tolerated. + * @return @p q. + */ cwist_h2_async_queue *cwist_h2_async_queue_acquire(cwist_h2_async_queue *q) { if (q) atomic_fetch_add_explicit(&q->refs, 1, memory_order_relaxed); return q; } +/** + * @brief Drop a reference on the queue. + * On the last reference: closes the wake descriptors, destroys the mutex, and + * frees the queue. Callers must not enqueue after close(). + * @param q Queue to release; NULL is tolerated. + */ void cwist_h2_async_queue_release(cwist_h2_async_queue *q) { if (!q) return; if (atomic_fetch_sub_explicit(&q->refs, 1, memory_order_acq_rel) != 1) return; @@ -329,6 +378,11 @@ int cwist_h2_async_queue_enqueue(cwist_h2_async_queue *q, uint32_t stream_id, return 0; } +/** + * @brief Wake descriptor to poll from the connection's event loop. + * @param q Queue to query. + * @return Read side of the wake fd, or -1 when @p q is NULL or has none. + */ static int cwist_h2_async_queue_fd(const cwist_h2_async_queue *q) { return q ? q->wake_rd : -1; } @@ -424,6 +478,15 @@ typedef struct h2_conn { cwist_h2_async_queue *async_q; } h2_conn; +/** + * @brief Initialize a connection state struct to RFC defaults. + * Zeros the struct, seeds peer defaults (frame size, initial window), sets up + * flow control, DoS-mitigation budgets, and the out_mu/fc_mu mutexes (fc_cond + * is torn down again if its init fails). No resources are freed here; pair + * with h2_conn_destroy(). + * @param hc Connection state to initialize. + * @param conn Owning HTTPS connection; may be NULL for test scaffolding. + */ static void h2_conn_init(h2_conn *hc, cwist_https_connection *conn) { memset(hc, 0, sizeof(*hc)); hc->conn = conn; @@ -456,9 +519,12 @@ static void h2_conn_init(h2_conn *hc, cwist_https_connection *conn) { } } -/* Wake handler threads parked waiting for send-window credit. Call after - * any update that may have restored credit (WINDOW_UPDATE applied, SETTINGS - * initial-window delta, stream abort/detach). */ +/** + * @brief Wake handler threads parked waiting for send-window credit. + * Call after any update that may have restored credit (WINDOW_UPDATE applied, + * SETTINGS initial-window delta, stream abort/detach). + * @param hc Connection whose fc_cond is broadcast. + */ static void h2_fc_credit_signal(h2_conn *hc) { if (!hc->fc_mu_init) return; pthread_mutex_lock(&hc->fc_mu); @@ -466,9 +532,16 @@ static void h2_fc_credit_signal(h2_conn *hc) { pthread_mutex_unlock(&hc->fc_mu); } -/* Detach @p s from send-credit waiters and block until none remain, so the - * stream can be freed without a parked handler thread waking up on a - * dangling pointer. */ +/** + * @brief Detach a stream from send-credit waiters and block until none + * remain. + * Marks the stream detached (parked threads then exit instead of touching + * it) and waits until all fc_waiters have left, so the stream can be freed + * without a parked handler thread waking up on a dangling pointer. Must be + * called before the stream is freed. + * @param hc Connection owning fc_mu/fc_cond. + * @param s Stream to quiesce. + */ static void h2_stream_drain_waiters(h2_conn *hc, h2_stream *s) { if (!hc->fc_mu_init) return; pthread_mutex_lock(&hc->fc_mu); @@ -478,6 +551,15 @@ static void h2_stream_drain_waiters(h2_conn *hc, h2_stream *s) { pthread_mutex_unlock(&hc->fc_mu); } +/** + * @brief Tear down a connection state struct and everything it owns. + * Drains send-credit waiters and fires on_close hooks per stream before + * freeing requests, sequenced-body assemblers, the HPACK dynamic table, the + * deferred-frame list, buffers, mutexes, and the async completion queue + * (pending completions are discarded). Safe only when no handler threads + * can still be using the streams. + * @param hc Connection state to destroy; not freed itself. + */ static void h2_conn_destroy(h2_conn *hc) { h2_stream *s = hc->streams; while (s) { @@ -527,6 +609,14 @@ static void h2_conn_destroy(h2_conn *hc) { } } +/** + * @brief Consume one inbound RST_STREAM credit (CVE-2023-44487 mitigation). + * Refills the budget from h2_max_rst_rate() based on elapsed time, capped at + * h2_max_rst_burst(). + * @param hc Connection whose budget is spent. + * @retval true Credit available and consumed. + * @retval false Budget depleted; caller must answer with GOAWAY. + */ static bool h2_conn_consume_rst_budget(h2_conn *hc) { uint64_t now = h2_now_ms(); uint64_t elapsed = (now > hc->rst_last_refill_ms) ? (now - hc->rst_last_refill_ms) : 0; @@ -551,8 +641,13 @@ static bool h2_conn_consume_rst_budget(h2_conn *hc) { return true; } -/* Consume one outbound RST_STREAM credit (issue #97: "Made You Reset"). - * Returns false when the budget is depleted — caller must send GOAWAY. */ +/** + * @brief Consume one outbound RST_STREAM credit (issue #97: "Made You + * Reset"). + * @param hc Connection whose outbound budget is spent. + * @retval false Budget depleted — caller must send GOAWAY. + * @retval true Credit available and consumed. + */ static bool h2_conn_consume_out_rst_budget(h2_conn *hc) { uint64_t now = h2_now_ms(); uint64_t elapsed = (now > hc->out_rst_last_refill_ms) ? (now - hc->out_rst_last_refill_ms) : 0; @@ -572,6 +667,14 @@ static bool h2_conn_consume_out_rst_budget(h2_conn *hc) { return true; } +/** + * @brief Consume one inbound PING credit (ping-flood mitigation). + * Refills CWIST_HTTP2_DEFAULT_PING_BURST at CWIST_HTTP2_DEFAULT_PING_RATE + * credits per second based on elapsed time. + * @param hc Connection whose ping budget is spent. + * @retval true Credit available and consumed. + * @retval false Budget depleted; caller should throttle/close. + */ static bool h2_conn_consume_ping_budget(h2_conn *hc) { uint64_t now = h2_now_ms(); uint64_t elapsed = (now > hc->ping_last_refill_ms) ? (now - hc->ping_last_refill_ms) : 0; @@ -596,6 +699,12 @@ static bool h2_conn_consume_ping_budget(h2_conn *hc) { return true; } +/** + * @brief Look up a live stream by ID. + * @param hc Connection to search. + * @param stream_id Stream identifier to find. + * @return Matching stream, or NULL when no such stream is open. + */ static h2_stream *h2_stream_find(h2_conn *hc, uint32_t stream_id) { h2_stream *s = hc->streams; while (s) { @@ -605,6 +714,16 @@ static h2_stream *h2_stream_find(h2_conn *hc, uint32_t stream_id) { return NULL; } +/** + * @brief Allocate and register a new stream on the connection. + * Receive credit is seeded to the 2GB INITIAL_WINDOW_SIZE we advertise; + * send credit starts at the peer's initial window. Pushes the stream onto + * the connection's list and bumps active_streams. + * @param hc Connection to attach the stream to. + * @param stream_id Identifier for the new stream. + * @return New stream, or NULL on allocation failure (active_streams + * unchanged). + */ static h2_stream *h2_stream_create(h2_conn *hc, uint32_t stream_id) { h2_stream *s = (h2_stream *)cwist_alloc(sizeof(*s)); if (!s) return NULL; @@ -622,6 +741,14 @@ static h2_stream *h2_stream_create(h2_conn *hc, uint32_t stream_id) { return s; } +/** + * @brief Remove and free a stream from the connection's table. + * Drains send-credit waiters first, fires the on_close hook when the stream + * was hook-taken, then destroys the request and body assembler and frees the + * stream. No-op when the ID is not present. + * @param hc Connection owning the stream table. + * @param stream_id Identifier of the stream to retire. + */ static void h2_stream_remove(h2_conn *hc, uint32_t stream_id) { h2_stream **pp = &hc->streams; while (*pp) { @@ -731,6 +858,13 @@ static bool h2_has_buffered_input(const cwist_https_connection *conn) { return conn->read_buf && conn->buf_len > 0; } +/** + * @brief Write bytes to the underlying transport. + * @param conn Connection; TLS via SSL_write when ssl is set, else write(2). + * @param buf Bytes to send. + * @param len Number of bytes to send. + * @return Bytes written, or the SSL_write/write result (negative on error). + */ static int h2_write(cwist_https_connection *conn, const void *buf, int len) { if (conn->ssl) return SSL_write(conn->ssl, buf, len); return write(conn->fd, buf, len); @@ -1200,6 +1334,11 @@ static h2_huffman_node *h2_huffman_root = NULL; * could hand a half-built trie to a decoder or exhaust the pool twice). */ static pthread_once_t h2_huffman_once = PTHREAD_ONCE_INIT; +/** + * @brief Allocate a node from the static Huffman node pool. + * @return Pooled node with children cleared, or NULL when the pool is + * exhausted. + */ static h2_huffman_node *h2_huffman_alloc_node(void) { if (h2_huffman_pool_used >= sizeof(h2_huffman_pool) / sizeof(h2_huffman_pool[0])) return NULL; h2_huffman_node *node = &h2_huffman_pool[h2_huffman_pool_used++]; @@ -1209,6 +1348,12 @@ static h2_huffman_node *h2_huffman_alloc_node(void) { return node; } +/** + * @brief Build the HPACK Huffman decode tree (RFC 7541 Appendix B). + * Runs once via pthread_once. Inserts all 257 codes into a pool-backed + * binary trie and precomputes the 8-bit root lookup table; on pool + * exhaustion the tree is left unset so decoding fails cleanly. + */ static void h2_huffman_init(void) { static const struct { uint32_t code; @@ -1307,6 +1452,15 @@ static void h2_huffman_init(void) { } } +/** + * @brief Decode an HPACK Huffman-coded string (RFC 7541 Appendix B). + * Lazily initializes the decode tree on first call. + * @param src Encoded bytes. + * @param src_len Number of encoded bytes. + * @param out_len Out: decoded length in bytes. + * @return Newly allocated NUL-terminated decoded string (caller frees), or + * NULL on initialization, allocation, or coding errors. + */ char *h2_huffman_decode(const unsigned char *src, size_t src_len, size_t *out_len) { pthread_once(&h2_huffman_once, h2_huffman_init); if (!h2_huffman_root) return NULL; @@ -1399,6 +1553,15 @@ char *h2_huffman_decode(const unsigned char *src, size_t src_len, size_t *out_le return out; } +/** + * @brief Decode one HPACK string literal (with optional Huffman coding). + * Reads the length prefix (7-bit prefix integer with the Huffman flag) and + * advances @p pos past the encoded bytes. + * @param buf Buffer holding the encoded header block. + * @param len Total length of @p buf. + * @param pos In/out: offset of the string; advanced past it on success. + * @return Newly allocated string (caller frees), or NULL on malformed input. + */ char *h2_decode_string(const unsigned char *buf, size_t len, size_t *pos) { if (*pos >= len) return NULL; bool huffman = (buf[*pos] & 0x80) != 0; @@ -1421,6 +1584,11 @@ char *h2_decode_string(const unsigned char *buf, size_t len, size_t *pos) { return out; } +/** + * @brief Resolve an RFC 7541 static table entry by 1-based index. + * @param index Static table index (1..61). + * @return Pointer to the static entry, or NULL for index 0 or out of range. + */ const cwist_http2_static_header *h2_static_header(uint32_t index) { size_t count = sizeof(cwist_http2_static_table) / sizeof(cwist_http2_static_table[0]); if (index == 0 || index >= count) return NULL; @@ -1489,6 +1657,13 @@ static int h2_hpack_set_capacity(h2_conn *hc, uint32_t new_size) { return 0; } +/** + * @brief Split a request target into path and query components. + * Splits at the first '?'; when a query is present the query map is reset and + * reparsed from it. Without a query the whole target becomes the path. + * @param req Request whose path/query/query_params are updated. + * @param path Raw :path pseudo-header value; must not be NULL. + */ static void h2_parse_path(cwist_http_request *req, const char *path) { const char *q = strchr(path, '?'); if (q) { @@ -1710,6 +1885,15 @@ static int h2_decode_header_block(h2_conn *hc, cwist_http_request *req, /* --- HPACK Response Encoder --- */ +/** + * @brief Encode an integer with an N-bit prefix (RFC 7541 section 5.1). + * Preserves the high bits of dst[0] outside the prefix mask. + * @param dst Destination buffer; dst[0] must already hold any flag bits. + * @param dst_cap Capacity of @p dst in bytes. + * @param value Integer to encode. + * @param prefix_bits Number of prefix bits (1..8). + * @return Bytes written, or 0 when @p dst_cap is too small. + */ size_t h2_encode_integer(unsigned char *dst, size_t dst_cap, uint32_t value, uint8_t prefix_bits) { uint8_t mask = (uint8_t)((1u << prefix_bits) - 1u); unsigned char first = dst[0] & ~mask; @@ -1730,6 +1914,14 @@ size_t h2_encode_integer(unsigned char *dst, size_t dst_cap, uint32_t value, uin return i; } +/** + * @brief Encode a literal string without Huffman coding (RFC 7541 section + * 5.2). + * @param dst Destination buffer. + * @param dst_cap Capacity of @p dst in bytes. + * @param str NUL-terminated string to encode. + * @return Bytes written, or 0 when @p dst_cap is too small. + */ size_t h2_encode_string(unsigned char *dst, size_t dst_cap, const char *str) { size_t len = strlen(str); dst[0] = 0x00; @@ -1739,6 +1931,11 @@ size_t h2_encode_string(unsigned char *dst, size_t dst_cap, const char *str) { return n + len; } +/** + * @brief Find a static table entry by header name (case-insensitive). + * @param name Header name to look up. + * @return Static table index of the first name match, or 0 when absent. + */ static int h2_static_table_find_name(const char *name) { size_t count = sizeof(cwist_http2_static_table) / sizeof(cwist_http2_static_table[0]); for (size_t i = 1; i < count; ++i) { @@ -1749,12 +1946,29 @@ static int h2_static_table_find_name(const char *name) { return 0; } -/* RFC 9113 section 8.1.1: responses with 1xx/204/304 status carry no content, and - * content-length is forbidden on 1xx/204 (and meaningless on 304 here). */ +/** + * @brief Whether a status code forbids a response body (RFC 9113 section + * 8.1.1). + * 1xx/204/304 responses carry no content; content-length is forbidden on + * 1xx/204 (and meaningless on 304 here). + * @param status_code HTTP status code. + * @retval true No body is permitted. + * @retval false A body may be sent. + */ static bool h2_status_forbids_body(int status_code) { return (status_code >= 100 && status_code < 200) || status_code == 204 || status_code == 304; } +/** + * @brief HPACK-encode the response header block into a caller buffer. + * Emits :status (indexed form for common codes), content-length (skipped in + * gRPC mode or for bodyless statuses), and the remaining headers. + * @param res Response whose headers are encoded. + * @param dst Destination buffer. + * @param dst_cap Capacity of @p dst in bytes. + * @param grpc_mode Skip content-length (gRPC framing supplies it). + * @return Bytes written, or 0 when @p dst_cap is too small. + */ static size_t h2_encode_response_headers(cwist_http_response *res, unsigned char *dst, size_t dst_cap, bool grpc_mode) { size_t pos = 0; @@ -2077,6 +2291,15 @@ static int h2_send_response_raw(cwist_https_connection *conn, uint32_t stream_id NULL); } +/** + * @brief Read exactly @p len bytes from the connection. + * Thin wrapper over h2_read_full for code paths that treat any short read as + * fatal. + * @param hc Connection to read from. + * @param buf Destination buffer. + * @param len Number of bytes to read. + * @return @p len on success, -1 on EOF, timeout, or socket error. + */ static int h2_read_all(h2_conn *hc, void *buf, int len) { /* h2_read_full tolerates WANT_READ on the non-blocking TLS sockets; * 0/EOF and real errors stay fatal. */ @@ -2335,10 +2558,19 @@ static int h2_send_seq_memory_body(h2_conn *hc, h2_stream *s, uint32_t stream_id * out_mu (so a whole frame never interleaves with dispatcher writes) and * flushed immediately for low-latency streaming. */ +/** + * @brief Take the outbound batch buffer mutex. + * Serializes frame writes between the dispatcher loop and hook-taken handler + * threads. No-op when the mutex was never initialized. + */ static void h2_out_lock(h2_conn *hc) { if (hc->out_mu_init) pthread_mutex_lock(&hc->out_mu); } +/** + * @brief Release the outbound batch buffer mutex. No-op when the mutex was + * never initialized. + */ static void h2_out_unlock(h2_conn *hc) { if (hc->out_mu_init) pthread_mutex_unlock(&hc->out_mu); } @@ -2419,6 +2651,19 @@ int cwist_http2_stream_send_headers(cwist_h2_stream *stream, int status, return rc; } +/** + * @brief Send DATA on a hook-taken stream, honoring flow-control credit. + * Splits @p data into peer_max_frame_size chunks. When the send window is + * exhausted the batch buffer is flushed and the calling handler thread parks + * on the connection's fc_cond until the dispatcher applies WINDOW_UPDATE + * credit or the stream goes away; credit is re-checked under out_mu before + * each frame is written. + * @param stream Stream to send on; must belong to a live connection. + * @param data Payload bytes. + * @param len Payload length. + * @return 0 when the whole payload was queued/written, -1 on send failure or + * stream/connection teardown. + */ int cwist_http2_stream_send_data(cwist_h2_stream *stream, const unsigned char *data, size_t len) { if (!stream || !stream->hc) return -1; h2_conn *hc = stream->hc; @@ -2515,12 +2760,24 @@ static void h2_hook_offer(h2_conn *hc, h2_stream *s) { } } +/** + * @brief Whether a response is a gRPC response. + * @param res Response to inspect. + * @retval true Its content-type starts with "application/grpc". + * @retval false Otherwise, or when no content-type is present. + */ static bool h2_response_is_grpc(cwist_http_response *res) { const char *ct = cwist_http_header_get(res->headers, "content-type"); if (!ct) ct = cwist_http_header_get(res->headers, "Content-Type"); return ct && strncmp(ct, "application/grpc", 16) == 0; } +/** + * @brief Case-insensitive lookup of a response header value. + * @param res Response whose header list is searched. + * @param name Header name to find. + * @return Pointer to the header value, or NULL when absent. + */ static const char *h2_grpc_header_value(cwist_http_response *res, const char *name) { for (cwist_http_header_node *h = res->headers; h; h = h->next) { if (h->key && h->key->data && h->value && h->value->data && @@ -2834,9 +3091,19 @@ static h2_stream *h2_request_stream_create(h2_conn *hc, uint32_t stream_id, bool return s; } -/* Decode a completed header block into the stream's request, translating - * decode failures into RST_STREAM. Returns 0 on success, 1 when a - * stream-level error was answered with RST_STREAM, -1 on connection error. */ +/** + * @brief Decode a completed header block into the stream's request, + * translating decode failures into RST_STREAM. + * @param hc Connection the stream belongs to. + * @param s Stream whose request receives the decoded headers. + * @param block HPACK header block bytes. + * @param block_len Length of @p block. + * @param is_request Whether this is a new request (pseudo-header validation). + * @retval 0 Success. + * @retval 1 Stream-level error answered with RST_STREAM (stream removed). + * @retval -1 Connection error (compression failure or outbound RST budget + * exhausted, GOAWAY already sent). + */ static int h2_decode_stream_headers(h2_conn *hc, h2_stream *s, const unsigned char *block, size_t block_len, bool is_request) { int rc = h2_decode_header_block(hc, s->req, block, block_len, is_request); @@ -2850,6 +3117,24 @@ static int h2_decode_stream_headers(h2_conn *hc, h2_stream *s, const unsigned ch return 0; } +/** + * @brief Handle a HEADERS frame opening (or continuing) a header block. + * With END_HEADERS the block is decoded immediately; otherwise buffering for + * CONTINUATION frames begins, subject to the max block size and concurrent + * stream limits (refused streams still consume their CONTINUATION sequence + * so framing stays in sync). + * @param hc Connection receiving the frame. + * @param stream_id Stream the header block targets. + * @param payload Frame payload (fragment of the header block). + * @param len Length of @p payload. + * @param end_headers Whether this fragment completes the block. + * @param end_stream Whether END_STREAM is set (deferred to block completion). + * @retval 0 Block buffered or decoded successfully. + * @retval 1 Stream refused (RST_STREAM sent) but framing continues. + * @retval -1 Protocol/alloc error; caller must send GOAWAY. + * @retval -2 Unrecoverable limit hit or outbound RST budget exhausted; + * GOAWAY already sent. + */ static int h2_begin_headers(h2_conn *hc, uint32_t stream_id, const unsigned char *payload, size_t len, bool end_headers, bool end_stream) { if (hc->expecting_continuation) { @@ -2922,6 +3207,23 @@ static int h2_begin_headers(h2_conn *hc, uint32_t stream_id, const unsigned char return 0; } +/** + * @brief Append one CONTINUATION fragment to the pending header block. + * Enforces the continuation-count (CVE-2024-27983) and total block-size + * limits; buffers grow by doubling. Fragments of a refused stream are + * consumed but discarded. On END_HEADERS the completed block is decoded + * into a request stream. + * @param hc Connection receiving the frame. + * @param stream_id Stream the continuation belongs to. + * @param payload Fragment bytes. + * @param len Length of @p payload. + * @param end_headers Whether this fragment completes the block. + * @retval 0 Fragment buffered (or discarded) successfully. + * @retval 1 Stream refused; framing continues. + * @retval -1 Protocol error or allocation failure; caller sends GOAWAY. + * @retval -2 Continuation/block limit exceeded; caller tears down the + * connection. + */ static int h2_handle_continuation(h2_conn *hc, uint32_t stream_id, const unsigned char *payload, size_t len, bool end_headers) { if (!hc->expecting_continuation || hc->cont_stream_id != stream_id) { @@ -2994,6 +3296,18 @@ static int h2_handle_continuation(h2_conn *hc, uint32_t stream_id, const unsigne /* --- SETTINGS Parser --- */ +/** + * @brief Apply a SETTINGS frame to connection and stream state. + * Validates the frame (6-byte entries, legal values per RFC 7540 section + * 6.5.2); an INITIAL_WINDOW_SIZE delta is propagated to every open stream's + * send window and parked senders are woken. + * @param hc Connection receiving the frame. + * @param payload Frame payload (ignored for ACKs). + * @param len Length of @p payload. + * @param ack Whether this is a SETTINGS ack (no-op). + * @retval 0 Settings applied (or ack ignored). + * @retval -1 Malformed frame or out-of-range value; caller sends GOAWAY. + */ static int h2_handle_settings(h2_conn *hc, const unsigned char *payload, size_t len, bool ack) { if (ack) { return 0; @@ -3051,6 +3365,20 @@ static int h2_handle_settings(h2_conn *hc, const unsigned char *payload, size_t /* --- WINDOW_UPDATE Parser --- */ +/** + * @brief Apply a WINDOW_UPDATE credit increment. + * Increments the connection (stream_id 0) or stream send window; increments + * that would exceed CWIST_HTTP2_MAX_WINDOW are protocol errors. Parked + * senders are woken. WINDOW_UPDATE for an unknown stream is ignored for + * leniency (RFC 7540). + * @param hc Connection receiving the frame. + * @param stream_id Stream to credit (0 = connection-level). + * @param payload 4-byte increment with the reserved high bit masked. + * @param len Must be exactly 4. + * @retval 0 Credit applied or ignored. + * @retval -1 Malformed frame, non-positive increment, or window overflow; + * caller sends GOAWAY. + */ static int h2_handle_window_update(h2_conn *hc, uint32_t stream_id, const unsigned char *payload, size_t len) { if (len != 4) return -1; @@ -3079,6 +3407,14 @@ static int h2_handle_window_update(h2_conn *hc, uint32_t stream_id, const unsign /* --- Preface Verification --- */ +/** + * @brief Read and validate the client connection preface. + * Waits out WANT_READ on non-blocking TLS sockets: the preface can arrive in + * a later TLS record than the handshake. + * @param hc Connection to verify. + * @retval 0 Preface received and matched. + * @retval -1 Short read or preface mismatch. + */ static int cwist_http2_verify_preface(h2_conn *hc) { char buffer[CWIST_HTTP2_CONNECTION_PREFACE_LEN]; /* The preface can arrive in a later TLS record than the handshake, so a @@ -3097,6 +3433,13 @@ static int cwist_http2_verify_preface(h2_conn *hc) { /* --- Alt-Svc Injection Helper --- */ +/** + * @brief Add an Alt-Svc advertisement for HTTP/3 to a response. + * When the connection has HTTP/3 enabled and the response has no Alt-Svc + * header yet, adds "h3=":"; ma=86400" using the socket's local port. + * @param conn Connection serving the response. + * @param res Response to augment. + */ static void h2_inject_alt_svc(cwist_https_connection *conn, cwist_http_response *res) { if (conn->http3_enabled && !cwist_http_header_get(res->headers, "Alt-Svc")) { struct sockaddr_storage ss; @@ -3166,6 +3509,15 @@ static int h2_async_drain(h2_conn *hc) { /* --- Connection Dispatcher --- */ +/** + * @brief Serve one HTTP/2 connection until it closes (no stream hooks). + * Convenience wrapper over cwist_http2_serve_connection_ex with NULL hooks. + * @param conn HTTPS connection with a completed TLS (or h2c) handshake. + * @param user_ctx Opaque context passed to @p handler. + * @param handler Request handler invoked per stream. + * @return CWIST_ERR_INT16 with err_i16 0 on orderly close, -1 on setup or + * preface failure. + */ cwist_error_t cwist_http2_serve_connection(cwist_https_connection *conn, void *user_ctx, cwist_http2_request_handler_func handler) { return cwist_http2_serve_connection_ex(conn, user_ctx, handler, NULL); @@ -3179,14 +3531,24 @@ typedef struct h2_session { uint64_t goaway_close_at; } h2_session; +/** + * @brief proto_state_free callback: destroy a parked session struct. + * @param p h2_session to tear down (connection state plus the struct). + */ static void h2_session_free(void *p) { h2_session *st = (h2_session *)p; h2_conn_destroy(&st->hc); cwist_free(st); } -/* Only a session with no stream in flight, no half-assembled header block - * and no hook deadline (gRPC) may leave its thread. */ +/** + * @brief Whether the session may be parked off its thread. + * Only a session with no stream in flight, no half-assembled header block, + * and no hook deadline (gRPC) may leave its thread. + * @param hc Connection state to check. + * @retval true Safe to park. + * @retval false Live work requires the connection thread. + */ static bool h2_can_park(h2_conn *hc) { if (hc->streams || hc->deferred_head || hc->cont_len > 0) return false; if (hc->hooks && hc->hooks->next_deadline_ms && hc->hooks->next_deadline_ms(hc->hook_ctx)) @@ -3194,6 +3556,21 @@ static bool h2_can_park(h2_conn *hc) { return true; } +/** + * @brief Serve one HTTP/2 connection until it closes. + * Verifies the preface, sends our SETTINGS, then runs the frame dispatcher: + * HEADERS/CONTINUATION, DATA, SETTINGS, PING, WINDOW_UPDATE, RST_STREAM, and + * GOAWAY, with per-connection DoS budgets and flow control. A session may + * be parked off its thread (cwist_https_park) and resumed via + * conn->proto_state; on exit the session is torn down and proto_state + * cleared. + * @param conn HTTPS connection with a completed TLS (or h2c) handshake. + * @param user_ctx Opaque context passed to @p handler and the hooks. + * @param handler Request handler invoked per stream. + * @param hooks Optional stream-lifetime hooks (may be NULL). + * @return CWIST_ERR_INT16 with err_i16 0 on orderly close, -1 on argument, + * allocation, or preface failure. + */ cwist_error_t cwist_http2_serve_connection_ex(cwist_https_connection *conn, void *user_ctx, cwist_http2_request_handler_func handler, const cwist_http2_stream_hooks *hooks) { @@ -3813,6 +4190,21 @@ cwist_error_t cwist_http2_serve_connection_ex(cwist_https_connection *conn, void /* HTTP/2 Server Push */ /* ------------------------------------------------------------------ */ +/** + * @brief Push a resource to the client (HTTP/2 server push). + * Sends a PUSH_PROMISE on the request's stream for a fresh server-initiated + * stream, then responds on that promised stream with a 200 and the supplied + * body. The :authority pseudo-header is copied from the request's Host + * header. + * @param req Request of the stream that triggered the push; its + * private_data must be the cwist_https_connection. + * @param path :path for the promised request. + * @param content_type Optional content-type header value. + * @param data Body bytes (may be NULL when @p data_len is 0). + * @param data_len Body length. + * @retval 0 Response queued/written successfully. + * @retval -1 Invalid arguments, missing Host header, or send failure. + */ int cwist_http2_push_resource(cwist_http_request *req, const char *path, const char *content_type, const unsigned char *data, size_t data_len) { if (!req || !req->private_data || !path) return -1; diff --git a/src/net/http/http2_flow_control.c b/src/net/http/http2_flow_control.c index 9821fb064..9650a5b2f 100644 --- a/src/net/http/http2_flow_control.c +++ b/src/net/http/http2_flow_control.c @@ -3,6 +3,14 @@ #include #include +/** @brief Clamp a value into a uint32 window range. + * + * @param value Value to clamp. + * @param minimum Lower bound, returned when @p value is below it. + * @param maximum Upper bound, returned when @p value is above it. + * @return @p value truncated to uint32 when inside the range, else the + * nearest bound. + */ static uint32_t cwist_http2_window_clamp(uint64_t value, uint32_t minimum, uint32_t maximum) { if (value < minimum) { return minimum; @@ -13,10 +21,31 @@ static uint32_t cwist_http2_window_clamp(uint64_t value, uint32_t minimum, uint3 return (uint32_t)value; } +/** @brief Absolute difference between two unsigned 64-bit values. + * + * @param left First value. + * @param right Second value. + * @return `left - right` when @p left is greater, `right - left` otherwise. + * Safe for any input because the subtraction happens on the larger + * operand first. + */ static uint64_t cwist_http2_abs_diff(uint64_t left, uint64_t right) { return left >= right ? left - right : right - left; } +/** @brief Recompute network quality, target window, and pacing rate. + * + * No-op until the first RTT sample marks the estimator initialized. Uses + * srtt + 4*rttvar as the effective delay: high jitter marks the network + * unstable, a delay above 200 ms marks it high-RTT, otherwise normal. The + * pacing multiplier scales with delay and gets a +1 bump when the network is + * not normal. The target window is clamped to + * [min_window, max_window], and the pacing rate is derived from the + * target window over the smoothed RTT, floored at 10x min_window so a + * single RTT spike cannot throttle the connection. + * + * @param flow_control Connection flow-control state to update in place. + */ static void cwist_http2_flow_control_adjust(cwist_http2_flow_control *flow_control) { uint64_t delay_us; uint64_t multiplier; @@ -57,6 +86,16 @@ static void cwist_http2_flow_control_adjust(cwist_http2_flow_control *flow_contr } } +/** @brief Serialize an HTTP/2 WINDOW_UPDATE frame into a caller buffer. + * + * @param buffer Destination buffer, must hold at least 13 bytes. + * @param buffer_len Available bytes in @p buffer. + * @param written Set to 0 on entry, then to 13 on success. + * @param stream_id Stream identifier; 0 writes a connection-level update. + * @param increment Flow-control increment, must be non-zero per RFC 9113. + * @return 0 on success, -1 if the arguments are invalid or the buffer is + * smaller than 13 bytes (nothing is written past @p buffer_len). + */ static int cwist_http2_write_window_update(uint8_t *buffer, size_t buffer_len, size_t *written, uint32_t stream_id, uint32_t increment) { if (written != NULL) { @@ -86,6 +125,22 @@ static int cwist_http2_write_window_update(uint8_t *buffer, size_t buffer_len, s return 0; } +/** @brief Initialize connection-level flow-control state. + * + * Windows below the protocol defaults are raised to the defaults, and the + * maximum window is clamped to CWIST_HTTP2_MAX_WINDOW. Zeroes the whole + * state, then seeds receive/target/min/send windows with the initial + * connection window, and grants half a window of initial pacing tokens at + * 10x the initial window in bytes per second as a provisional pacing rate + * until RTT samples recalibrate it. + * + * @param flow_control Connection flow-control state to initialize. + * @param initial_connection_window Requested initial connection window; + * values below CWIST_HTTP2_INITIAL_CONNECTION_WINDOW select the default. + * @param max_connection_window Upper bound for the dynamic window; + * raised to at least the initial window and capped at + * CWIST_HTTP2_MAX_WINDOW. + */ void cwist_http2_flow_control_init(cwist_http2_flow_control *flow_control, uint32_t initial_connection_window, uint32_t max_connection_window) { @@ -116,6 +171,20 @@ void cwist_http2_flow_control_init(cwist_http2_flow_control *flow_control, flow_control->pacing_rate_bytes_per_sec = initial_connection_window * 10U; } +/** @brief Initialize per-stream flow-control state. + * + * Windows below the protocol defaults are raised to the defaults, and the + * maximum window is clamped to CWIST_HTTP2_MAX_WINDOW. Zeroes the whole + * state, then records the stream identifier and seeds receive/target/min/send + * windows with the initial stream window. + * + * @param flow_control Stream flow-control state to initialize. + * @param stream_id HTTP/2 stream identifier to associate with the state. + * @param initial_stream_window Requested initial stream window; values below + * CWIST_HTTP2_INITIAL_STREAM_WINDOW select the default. + * @param max_stream_window Upper bound for the dynamic window; raised to at + * least the initial window and capped at CWIST_HTTP2_MAX_WINDOW. + */ void cwist_http2_stream_flow_control_init(cwist_http2_stream_flow_control *flow_control, uint32_t stream_id, uint32_t initial_stream_window, uint32_t max_stream_window) { @@ -142,6 +211,18 @@ void cwist_http2_stream_flow_control_init(cwist_http2_stream_flow_control *flow_ flow_control->send_window = initial_stream_window; } +/** @brief Feed a new RTT sample into the smoothed estimator and retune. + * + * Ignores NULL state and zero samples. The first sample seeds srtt and sets + * rttvar to half of it; later samples feed exponential updates + * (rttvar: 3/4 old + 1/4 deviation, srtt: 7/8 old + 1/8 sample) and then + * trigger cwist_http2_flow_control_adjust() to recompute network quality, + * the target window, and the pacing rate. + * + * @param flow_control Connection flow-control state holding the estimator. + * @param rtt_sample_us Measured round-trip time in microseconds; must be + * non-zero to be accepted. + */ void cwist_http2_flow_control_update_rtt(cwist_http2_flow_control *flow_control, uint64_t rtt_sample_us) { uint64_t difference; @@ -161,6 +242,14 @@ void cwist_http2_flow_control_update_rtt(cwist_http2_flow_control *flow_control, cwist_http2_flow_control_adjust(flow_control); } +/** @brief Account received DATA bytes against the connection receive window. + * + * @param flow_control Connection flow-control state. + * @param bytes Number of bytes received from the peer. + * @retval true @p bytes fit in the remaining receive window and were debited. + * @retval false @p flow_control is NULL or @p bytes exceed the remaining + * window; nothing is debited. + */ bool cwist_http2_flow_control_receive(cwist_http2_flow_control *flow_control, uint32_t bytes) { if (flow_control == NULL || bytes > flow_control->receive_window) { return false; @@ -169,6 +258,14 @@ bool cwist_http2_flow_control_receive(cwist_http2_flow_control *flow_control, ui return true; } +/** @brief Account received DATA bytes against a stream receive window. + * + * @param flow_control Stream flow-control state. + * @param bytes Number of bytes received from the peer on the stream. + * @retval true @p bytes fit in the remaining receive window and were debited. + * @retval false @p flow_control is NULL or @p bytes exceed the remaining + * window; nothing is debited. + */ bool cwist_http2_stream_flow_control_receive(cwist_http2_stream_flow_control *flow_control, uint32_t bytes) { if (flow_control == NULL || bytes > flow_control->receive_window) { @@ -178,6 +275,15 @@ bool cwist_http2_stream_flow_control_receive(cwist_http2_stream_flow_control *fl return true; } +/** @brief Record application-consumed bytes for the connection. + * + * Adds @p bytes to the pending WINDOW_UPDATE accumulator, saturating at + * UINT32_MAX instead of wrapping. + * + * @param flow_control Connection flow-control state. + * @param bytes Number of bytes the application consumed from the receive + * buffer. + */ void cwist_http2_flow_control_consume(cwist_http2_flow_control *flow_control, uint32_t bytes) { if (flow_control != NULL) { flow_control->pending_update += bytes > UINT32_MAX - flow_control->pending_update @@ -186,6 +292,15 @@ void cwist_http2_flow_control_consume(cwist_http2_flow_control *flow_control, ui } } +/** @brief Record application-consumed bytes for a stream. + * + * Adds @p bytes to the stream's pending WINDOW_UPDATE accumulator, + * saturating at UINT32_MAX instead of wrapping. + * + * @param flow_control Stream flow-control state. + * @param bytes Number of bytes the application consumed from the stream's + * receive buffer. + */ void cwist_http2_stream_flow_control_consume(cwist_http2_stream_flow_control *flow_control, uint32_t bytes) { if (flow_control != NULL) { @@ -195,11 +310,24 @@ void cwist_http2_stream_flow_control_consume(cwist_http2_stream_flow_control *fl } } -/* Retune the receive target toward 2x the measured bandwidth-delay product: - * bytes consumed since the previous update, projected over one SRTT. Each - * retune moves at most a 2x/0.5x step away from the current target, so a +/** @brief Retune the receive target toward 2x the measured bandwidth-delay + * product. + * + * Projects the bytes consumed since the previous update over one SRTT. + * Each retune moves at most a 2x/0.5x step away from the current target, so a * micro-interval jitter sample cannot slam the window between its floor and - * ceiling (oscillation under bursty load). */ + * ceiling (oscillation under bursty load). + * + * @param pending_update Bytes consumed since the previous window update. + * @param srtt_us Smoothed round-trip time in microseconds. + * @param interval_us Elapsed time since the previous update in microseconds. + * @param current_target Window target currently in effect. + * @param minimum Lower bound for the returned target. + * @param maximum Upper bound for the returned target. + * @return New target window, clamped to [minimum, maximum] and bounded to a + * 2x grow / 0.5x shrink step from @p current_target; the current + * target when @p interval_us is zero. + */ static uint32_t cwist_http2_flow_control_retune_target(uint32_t pending_update, uint64_t srtt_us, uint64_t interval_us, uint32_t current_target, uint32_t minimum, @@ -224,14 +352,43 @@ static uint32_t cwist_http2_flow_control_retune_target(uint32_t pending_update, return target; } -/* Credit to hand back to the peer: top the window up to the target, but - * never refund less than what the application has actually consumed. */ +/** @brief Compute the WINDOW_UPDATE credit to hand back to the peer. + * + * Tops the window up to the target, but never refunds less than what the + * application has actually consumed. + * + * @param pending_update Bytes consumed by the application awaiting refund. + * @param receive_window Current receive window. + * @param target_window Desired steady-state window. + * @return The larger of @p pending_update and the top-up from + * @p receive_window to @p target_window. + */ static uint32_t cwist_http2_flow_control_increment(uint32_t pending_update, uint32_t receive_window, uint32_t target_window) { uint32_t top_up = receive_window < target_window ? target_window - receive_window : 0U; return pending_update > top_up ? pending_update : top_up; } +/** @brief Emit a connection-level WINDOW_UPDATE when enough credit is due. + * + * Does nothing until at least half the target window has been consumed + * (unless @p force), then retunes the target against the measured + * bandwidth-delay product and serializes a WINDOW_UPDATE for stream 0 into + * the caller's buffer. On success the receive window is credited with the + * increment (saturating at CWIST_HTTP2_MAX_WINDOW), the pending accumulator + * is cleared, and the update timestamp is advanced. + * + * @param flow_control Connection flow-control state. + * @param buffer Destination buffer for the serialized frame. + * @param buffer_len Available bytes in @p buffer (at least 13 required). + * @param written Set to 0 when no frame is emitted, to 13 on success. + * @param now_us Current time in microseconds; drives retuning. + * @param force Emit the update even when less than half the target window + * is pending. + * @return 1 when a WINDOW_UPDATE frame was written, 0 when no update is due + * or @p flow_control is NULL, -1 when the frame could not be + * serialized. + */ int cwist_http2_flow_control_maybe_window_update(cwist_http2_flow_control *flow_control, uint8_t *buffer, size_t buffer_len, size_t *written, uint64_t now_us, bool force) { @@ -266,6 +423,30 @@ int cwist_http2_flow_control_maybe_window_update(cwist_http2_flow_control *flow_ return 1; } +/** @brief Emit a stream-level WINDOW_UPDATE when enough credit is due. + * + * Server-side only client-initiated (odd, non-zero) streams may receive a + * stream-level WINDOW_UPDATE; other stream IDs return 0 without emitting + * anything. Requires at least half the target window in pending credit + * unless @p force. When due, the stream target is retuned against the + * connection's RTT estimate and a WINDOW_UPDATE for the stream is + * serialized; the stream receive window is credited (saturating at + * CWIST_HTTP2_MAX_WINDOW), the pending accumulator is cleared, and the + * update timestamp is advanced. + * + * @param connection_flow_control Connection flow-control state supplying the + * RTT estimate. + * @param stream_flow_control Stream flow-control state to update. + * @param buffer Destination buffer for the serialized frame. + * @param buffer_len Available bytes in @p buffer (at least 13 required). + * @param written Set to 0 when no frame is emitted, to 13 on success. + * @param now_us Current time in microseconds; drives retuning. + * @param force Emit the update even when less than half the target window + * is pending. + * @return 1 when a WINDOW_UPDATE frame was written, 0 when the stream is + * ineligible or no update is due, -1 when @p flow_control arguments + * are NULL or the frame could not be serialized. + */ int cwist_http2_stream_flow_control_maybe_window_update( cwist_http2_flow_control *connection_flow_control, cwist_http2_stream_flow_control *stream_flow_control, uint8_t *buffer, size_t buffer_len, @@ -314,6 +495,13 @@ int cwist_http2_stream_flow_control_maybe_window_update( return 1; } +/** @brief Credit the connection send window from a WINDOW_UPDATE frame. + * + * Saturating add: credit is capped at CWIST_HTTP2_MAX_WINDOW. + * + * @param flow_control Connection flow-control state. + * @param increment Send-window increment from the peer. + */ void cwist_http2_flow_control_add_send_window(cwist_http2_flow_control *flow_control, uint32_t increment) { if (flow_control != NULL) { @@ -324,6 +512,13 @@ void cwist_http2_flow_control_add_send_window(cwist_http2_flow_control *flow_con } } +/** @brief Credit a stream send window from a WINDOW_UPDATE frame. + * + * Saturating add: credit is capped at CWIST_HTTP2_MAX_WINDOW. + * + * @param flow_control Stream flow-control state. + * @param increment Send-window increment from the peer. + */ void cwist_http2_stream_flow_control_add_send_window(cwist_http2_stream_flow_control *flow_control, uint32_t increment) { if (flow_control != NULL) { @@ -333,6 +528,23 @@ void cwist_http2_stream_flow_control_add_send_window(cwist_http2_stream_flow_con } } +/** @brief Compute how much of a send request may proceed right now. + * + * Refills the pacing token bucket from the elapsed time since the last call, + * capping the refill at one target window's worth of tokens (and capping the + * elapsed computation so it stays inside uint64 regardless of idle time). + * The result is the smallest of the requested size, the connection send + * window, the stream send window, and the available pacing tokens. + * + * @param connection_flow_control Connection flow-control state holding the + * pacing bucket. + * @param stream_flow_control Stream flow-control state holding the stream + * send window. + * @param requested Number of bytes the caller wants to send. + * @param now_us Current time in microseconds. + * @return Allowed byte count; 0 when either flow-control pointer is NULL or + * no credit of any kind is available. + */ size_t cwist_http2_flow_control_pacing_allowance( cwist_http2_flow_control *connection_flow_control, const cwist_http2_stream_flow_control *stream_flow_control, size_t requested, uint64_t now_us) { @@ -381,6 +593,23 @@ size_t cwist_http2_flow_control_pacing_allowance( return allowed; } +/** @brief Reserve send credit for an outgoing write. + * + * Checks cwist_http2_flow_control_pacing_allowance() first; on success the + * requested bytes are debited from the connection send window, the stream + * send window, and the pacing token bucket, so the caller must actually + * transmit the bytes it reserved. + * + * @param connection_flow_control Connection flow-control state. + * @param stream_flow_control Stream flow-control state. + * @param bytes Number of bytes to reserve. + * @param now_us Current time in microseconds, forwarded to the allowance + * computation. + * @retval true The reservation succeeded and all three credit pools were + * debited by @p bytes. + * @retval false Insufficient connection/stream window or pacing tokens; + * nothing is debited. + */ bool cwist_http2_flow_control_reserve_send(cwist_http2_flow_control *connection_flow_control, cwist_http2_stream_flow_control *stream_flow_control, uint32_t bytes, uint64_t now_us) { diff --git a/src/net/http/http3.c b/src/net/http/http3.c index d1db92c5c..c17935ef8 100644 --- a/src/net/http/http3.c +++ b/src/net/http/http3.c @@ -79,6 +79,11 @@ static pthread_mutex_t g_h3_global_mtx = PTHREAD_MUTEX_INITIALIZER; static int g_h3_global_ref = 0; +/** + * @brief Take a process-wide reference on the lsquic global state. + * Initializes lsquic on the first reference; later calls only bump the + * reference count. Thread-safe via @ref g_h3_global_mtx. + */ static void h3_global_init(void) { pthread_mutex_lock(&g_h3_global_mtx); if (g_h3_global_ref == 0) { @@ -88,6 +93,11 @@ static void h3_global_init(void) { pthread_mutex_unlock(&g_h3_global_mtx); } +/** + * @brief Release a reference taken by h3_global_init(). + * Runs lsquic_global_cleanup() when the last reference is dropped. + * Thread-safe; safe to call with no matching init (no-op). + */ static void h3_global_cleanup(void) { pthread_mutex_lock(&g_h3_global_mtx); if (g_h3_global_ref > 0) { @@ -168,6 +178,12 @@ typedef struct h3_conn_ctx { /* Every lsquic_conn_get_ctx(conn) callsite below reads this wrapper's * ->h3_ctx instead of casting the raw ctx pointer directly. */ +/** + * @brief Extract the shared server context from a connection's context wrapper. + * @param conn Connection whose ctx was set up as h3_conn_ctx_t, or NULL. + * @return The shared cwist_http3_context*, or NULL if @p conn or its ctx is + * NULL. Not owned by the caller. + */ static inline cwist_http3_context *h3_shared_ctx(lsquic_conn_t *conn) { h3_conn_ctx_t *cc = conn ? (h3_conn_ctx_t *)lsquic_conn_get_ctx(conn) : NULL; return cc ? cc->h3_ctx : NULL; @@ -194,6 +210,12 @@ typedef struct cwist_wt_handle { #define CWIST_WT_HANDLE_MAGIC UINT64_C(0x4357495354575431) #define CWIST_WT_HANDLE_VERSION 1 +/** + * @brief Allocate a new typed WebTransport handle wrapping @p ptr. + * @param kind Handle kind tag (session or stream). + * @param ptr Underlying lsquic object to wrap; must not be NULL. + * @return The new handle, or NULL if @p ptr is NULL or allocation fails. + */ static cwist_wt_handle_t *cwist_wt_handle_new(cwist_wt_handle_kind_t kind, void *ptr) { if (!ptr) return NULL; cwist_wt_handle_t *handle = calloc(1, sizeof(*handle)); @@ -205,6 +227,12 @@ static cwist_wt_handle_t *cwist_wt_handle_new(cwist_wt_handle_kind_t kind, void return handle; } +/** + * @brief Free a handle and, recursively, all of its child handles. + * Detaches the handle from its parent's child list first. Safe to call + * with NULL. + * @param Handle to free; ownership is consumed. + */ static void cwist_wt_handle_free(cwist_wt_handle_t *handle) { if (!handle) return; while (handle->first_child) { @@ -235,6 +263,10 @@ static void cwist_wt_handle_free(cwist_wt_handle_t *handle) { free(handle); } +/** + * @brief Link @p child into @p parent's child list. + * The child is inserted at the head of the list. No-op if either is NULL. + */ static void cwist_wt_handle_attach(cwist_wt_handle_t *parent, cwist_wt_handle_t *child) { if (!parent || !child) return; child->parent = parent; @@ -242,6 +274,13 @@ static void cwist_wt_handle_attach(cwist_wt_handle_t *parent, cwist_wt_handle_t parent->first_child = child; } +/** + * @brief Validate an opaque pointer and cast it to a typed handle. + * Checks the magic, version, kind tag, and wrapped pointer. + * @param handle Opaque pointer supplied by the application. + * @param kind Expected handle kind. + * @return The typed handle, or NULL if any validation check fails. + */ static cwist_wt_handle_t *cwist_wt_handle_cast(void *handle, cwist_wt_handle_kind_t kind) { cwist_wt_handle_t *h = (cwist_wt_handle_t *)handle; if (!h || h->magic != CWIST_WT_HANDLE_MAGIC || h->version != CWIST_WT_HANDLE_VERSION || @@ -251,11 +290,21 @@ static cwist_wt_handle_t *cwist_wt_handle_cast(void *handle, cwist_wt_handle_kin return h; } +/** + * @brief Resolve an opaque handle to its underlying lsquic stream. + * @return The wrapped lsquic_stream_t*, or NULL if @p handle is not a valid + * stream-kind handle. Not owned by the caller. + */ static lsquic_stream_t *cwist_wt_handle_stream(void *handle) { cwist_wt_handle_t *h = cwist_wt_handle_cast(handle, CWIST_WT_HANDLE_STREAM); return h ? (lsquic_stream_t *)h->ptr : NULL; } +/** + * @brief Resolve an opaque handle to its underlying WebTransport session. + * @return The wrapped lsquic_wt_session_t*, or NULL if @p handle is not a + * valid session-kind handle. Not owned by the caller. + */ static lsquic_wt_session_t *cwist_wt_handle_session(void *handle) { cwist_wt_handle_t *h = cwist_wt_handle_cast(handle, CWIST_WT_HANDLE_SESSION); return h ? (lsquic_wt_session_t *)h->ptr : NULL; @@ -286,9 +335,13 @@ typedef struct cwist_h3_hset { size_t decode_off; } cwist_h3_hset_t; -/* The engine loop is single-threaded and all hsi callbacks fire from it (or - * from engine destroy after the loop has exited), so the list needs no - * locking. */ +/** + * @brief Push an hset onto its owner's intrusive list of live hsets. + * + * No-op if @p ctx or @p hset is NULL. The engine loop is single-threaded + * and all hsi callbacks fire from it (or from engine destroy after the loop + * has exited), so the list needs no locking. + */ static void cwist_h3_hset_track(cwist_http3_context *ctx, cwist_h3_hset_t *hset) { if (!ctx || !hset) return; hset->owner = ctx; @@ -298,6 +351,10 @@ static void cwist_h3_hset_track(cwist_http3_context *ctx, cwist_h3_hset_t *hset) ctx->hsets = hset; } +/** + * @brief Remove an hset from its owner's tracking list. + * No-op for NULL or untracked hsets. Does not free @p hset itself. + */ static void cwist_h3_hset_untrack(cwist_h3_hset_t *hset) { if (!hset || !hset->owner) return; *hset->prev = hset->next; @@ -307,10 +364,14 @@ static void cwist_h3_hset_untrack(cwist_h3_hset_t *hset) { hset->owner = NULL; } -/* lsquic never calls hsi_discard_header_set for streams that are still open - * when the engine is destroyed; free whatever is still tracked. Must run - * after lsquic_engine_destroy() so a discard issued during destroy has - * already untracked its hset (no double-free). */ +/** + * @brief Free all hsets still tracked by @p ctx. + * + * lsquic never calls hsi_discard_header_set for streams that are still open + * when the engine is destroyed, so anything still tracked leaks without + * this. Must run after lsquic_engine_destroy() so a discard issued during + * destroy has already untracked its hset (no double-free). + */ static void cwist_h3_hset_sweep(cwist_http3_context *ctx) { if (!ctx) return; while (ctx->hsets) { @@ -320,6 +381,13 @@ static void cwist_h3_hset_sweep(cwist_http3_context *ctx) { } } +/** + * @brief hsi_create_header_set callback: allocate a header set for @p stream. + * @param hsi_ctx Tracking context (cwist_http3_context); may be NULL. + * @param is_push_promise Unused in this implementation. + * @return New tracked cwist_h3_hset_t, or NULL on OOM. Ownership passes to + * lsquic, which releases it via cwist_h3_hsi_discard(). + */ static void *cwist_h3_hsi_create(void *hsi_ctx, lsquic_stream_t *stream, int is_push_promise) { (void)is_push_promise; cwist_h3_hset_t *hset = calloc(1, sizeof(*hset)); @@ -329,14 +397,24 @@ static void *cwist_h3_hsi_create(void *hsi_ctx, lsquic_stream_t *stream, int is_ return hset; } +/** + * @brief hsi_prepare_decode callback: reserve decode storage for a header. + * @param hset_p Header set. + * @param xhdr Previous header when lsquic retries with a larger value + * requirement, NULL for a fresh slot. + * @param req_space Required value capacity in bytes. + * @return The (possibly resized) header slot, or NULL if the request exceeds + * the slot count, per-string, or decode-buffer limits. + * + * When @p xhdr is non-NULL the previously decoded name must be preserved: + * only the value capacity is grown. Reinitializing the slot here loses + * :path/Cookie. + */ static struct lsxpack_header *cwist_h3_hsi_prepare(void *hset_p, struct lsxpack_header *xhdr, size_t req_space) { cwist_h3_hset_t *hset = hset_p; if (!hset) return NULL; - /* lsquic calls us again with the same header when its initial output - * buffer was too small. Preserve the decoded name and grow only the - * value capacity; reinitializing the slot here loses :path/Cookie. */ if (xhdr) { if (req_space > LSXPACK_MAX_STRLEN || xhdr->name_offset < 0 || (size_t)xhdr->name_offset >= sizeof(hset->decode_buf) || @@ -357,6 +435,14 @@ static struct lsxpack_header *cwist_h3_hsi_prepare(void *hset_p, struct lsxpack_ return &hset->headers[hset->count]; } +/** + * @brief hsi_process_header callback: account for a completed header. + * @param hset_p Header set. + * @param xhdr Completed header; the QPACK decoder exposes the exact storage + * it used. NULL marks the end of a header block. + * @retval 0 Header accepted and accounted in the decode buffer. + * @retval -1 Header would overflow the decode buffer; logged to stderr. + */ static int cwist_h3_hsi_process_header(void *hset_p, struct lsxpack_header *xhdr) { cwist_h3_hset_t *hset = hset_p; /* A NULL header marks the end of a header block. */ @@ -375,6 +461,10 @@ static int cwist_h3_hsi_process_header(void *hset_p, struct lsxpack_header *xhdr return 0; } +/** + * @brief hsi_discard_header_set callback: untrack and free a header set. + * @param hset_p Header set previously returned by cwist_h3_hsi_create(). + */ static void cwist_h3_hsi_discard(void *hset_p) { cwist_h3_hset_untrack((cwist_h3_hset_t *)hset_p); free(hset_p); @@ -395,6 +485,11 @@ static const struct lsquic_webtransport_if cwist_h3_wt_if; /* SSL context callback */ /* ------------------------------------------------------------------ */ +/** + * @brief ea_get_ssl_ctx callback: pick the server SSL_CTX for a connection. + * @param peer_ctx The cwist_http3_context registered as stream_if ctx. + * @return Its SSL_CTX, or NULL if @p peer_ctx is NULL. + */ static SSL_CTX *cwist_h3_get_ssl_ctx(void *peer_ctx, const struct sockaddr *local) { (void)local; cwist_http3_context *h3_ctx = (cwist_http3_context *)peer_ctx; @@ -405,6 +500,19 @@ static SSL_CTX *cwist_h3_get_ssl_ctx(void *peer_ctx, const struct sockaddr *loca /* Packet-out callback */ /* ------------------------------------------------------------------ */ +/** + * @brief Build the ancillary-data block for one outgoing UDP datagram. + * @param msg Message header to populate (msg_control/msg_controllen). + * @param cbuf Control buffer, zeroed here. + * @param cbuf_sz Capacity of @p cbuf. + * @param spec lsquic output spec carrying addresses and ECN bits. + * @param gso_seg UDP_SEGMENT segment size, or 0 to omit the cmsg (Linux only). + * + * Adds, as platform support allows: UDP_SEGMENT (Linux GSO), IP_PKTINFO / + * IP_SENDSRCADDR / IPV6_PKTINFO with a non-anyhole local source address, and + * IP_TOS / IPV6_TCLASS for ECN. msg_control is reset to NULL when nothing + * was queued. + */ static void h3_setup_cmsg(struct msghdr *msg, char *cbuf, size_t cbuf_sz, const struct lsquic_out_spec *spec, uint16_t gso_seg) { msg->msg_control = cbuf; @@ -496,6 +604,13 @@ static void h3_setup_cmsg(struct msghdr *msg, char *cbuf, size_t cbuf_sz, } } +/** + * @brief Send a single lsquic output spec as one non-blocking datagram. + * @param udp_fd Non-blocking UDP socket. + * @param spec Spec carrying destination, source address, ECN, and iovecs. + * @return sendmsg() result: bytes written, or -1 with errno set + * (EAGAIN/EWOULDBLOCK when the socket buffer is full). + */ static int h3_send_one(int udp_fd, const struct lsquic_out_spec *spec) { char ctrl[256]; struct msghdr msg = {0}; @@ -517,12 +632,21 @@ static int h3_send_one(int udp_fd, const struct lsquic_out_spec *spec) { #define SOL_UDP 17 #endif +/** + * @brief Total payload length across all iovecs of a spec. + */ static size_t h3_spec_len(const struct lsquic_out_spec *spec) { size_t n = 0; for (unsigned k = 0; k < spec->iovlen; k++) n += spec->iov[k].iov_len; return n; } +/** + * @brief Whether two output specs share destination and local addresses. + * + * Used to coalesce equal-size datagrams into one UDP_SEGMENT GSO send; + * differing ECN marks also split a run (checked by the caller). + */ static bool h3_same_dest(const struct lsquic_out_spec *a, const struct lsquic_out_spec *b) { if (!a->dest_sa || !b->dest_sa) return false; if (a->dest_sa->sa_family != b->dest_sa->sa_family) return false; @@ -561,6 +685,18 @@ static bool h3_same_dest(const struct lsquic_out_spec *a, const struct lsquic_ou * 0 means GSO is confirmed working. */ static int h3_gso_state = -1; /* -1 = unknown, 0 = enabled, 1 = disabled */ +/** + * @brief Fallback sender used when UDP GSO is disabled: batches specs into + * sendmmsg(2) calls of up to 64 messages. + * @param udp_fd Non-blocking UDP socket. + * @param specs Output spec array. + * @param i Index of the first spec not yet sent. + * @param n_specs Total number of specs. + * @return Number of specs consumed (>= @p i), or -1 if the very first send + * failed with a hard error. EAGAIN/EWOULDBLOCK stops the batch + * without being reported as an error; a failed batch start falls + * back to h3_send_one() for that single spec. + */ static int h3_sendmmsg_batch(int udp_fd, const struct lsquic_out_spec *specs, unsigned i, unsigned n_specs) { struct mmsghdr msgs[64]; @@ -607,6 +743,21 @@ static int h3_sendmmsg_batch(int udp_fd, const struct lsquic_out_spec *specs, un } #endif +/** + * @brief ea_packets_out callback: emit lsquic's queued UDP packets. + * @param ctx Points at the UDP socket fd (int). + * @param specs Packets to send. + * @param n_specs Number of entries in @p specs. + * @return Number of specs consumed, or -1 if none could be sent and the + * first attempt hit a hard error. + * + * Linux: one-time GSO probe (CWIST_H3_NO_GSO=1 disables); runs of equal-size + * datagrams to the same peer are coalesced into a single UDP_SEGMENT + * sendmsg, with a permanent fallback to h3_sendmmsg_batch() if the kernel + * rejects GSO. EAGAIN/EWOULDBLOCK and transient ICMP-style errors + * (ECONNREFUSED/ENETUNREACH/EHOSTUNREACH/EMSGSIZE) consume the run without + * failing the callback. Non-Linux: plain per-spec sendmsg loop. + */ static int cwist_h3_packets_out(void *ctx, const struct lsquic_out_spec *specs, unsigned n_specs) { int udp_fd = *(int *)ctx; #if defined(__linux__) @@ -702,6 +853,13 @@ static int cwist_h3_packets_out(void *ctx, const struct lsquic_out_spec *specs, /* ALPN selection callback (BoringSSL) */ /* ------------------------------------------------------------------ */ +/** + * @brief ALPN selection callback (BoringSSL): pick "h3" or "h3-29". + * @param out/onout Receives a pointer into the client's protocol list. + * @retval SSL_TLSEXT_ERR_OK A supported protocol was selected. + * @retval SSL_TLSEXT_ERR_NOACK No supported protocol offered; the handshake + * proceeds without ALPN. + */ static int cwist_h3_alpn_select_cb(SSL *ssl, const uint8_t **out, uint8_t *outlen, const uint8_t *in, unsigned inlen, void *arg) { (void)ssl; @@ -730,11 +888,25 @@ static int cwist_h3_alpn_select_cb(SSL *ssl, const uint8_t **out, uint8_t *outle /* Stream callbacks */ /* ------------------------------------------------------------------ */ +/** + * @brief lsquic logger sink: write one log buffer to stderr. + * @return Number of bytes written. + */ static int cwist_h3_log_stderr(void *ctx, const char *buf, size_t len) { (void)ctx; return (int)fwrite(buf, 1, len, stderr); } +/** + * @brief on_new_conn callback: allocate and install the per-connection ctx. + * @param stream_if_ctx The shared cwist_http3_context. + * @return New h3_conn_ctx_t (stored on the connection), or NULL on OOM. + * + * On OOM the connection proceeds with a NULL ctx: every consumer tolerates + * that, but the connection gets no request dispatch or datagram support. + * The wrapper carries the shared config plus this connection's own + * mutex-guarded datagram queue. + */ static lsquic_conn_ctx_t *cwist_h3_on_new_conn(void *stream_if_ctx, lsquic_conn_t *conn) { cwist_http3_context *h3_ctx = stream_if_ctx; h3_conn_ctx_t *cc = (h3_conn_ctx_t *)calloc(1, sizeof(*cc)); @@ -754,6 +926,14 @@ static lsquic_conn_ctx_t *cwist_h3_on_new_conn(void *stream_if_ctx, lsquic_conn_ return (lsquic_conn_ctx_t *)cc; } +/** + * @brief on_conn_closed callback: log the close status and free the + * per-connection ctx, including any queued datagrams. + * + * Deliberately avoids lsquic_conn_get_info(): it lazily allocates the + * bandwidth sampler even while the connection is being destroyed, leaking it + * under LSAN. Stats must be collected while the connection is alive. + */ static void cwist_h3_on_conn_closed(lsquic_conn_t *conn) { if (!conn) return; char errbuf[256] = {0}; @@ -781,11 +961,15 @@ static void cwist_h3_on_conn_closed(lsquic_conn_t *conn) { } } -/* lsquic stream_if callback: a CONNECTION_CLOSE frame arrived from the peer. +/** + * @brief on_conncloseframe_received callback: a CONNECTION_CLOSE frame + * arrived from the peer. + * * Record it on the shared context so post-serve diagnostics can see why the * peer went away without scraping the CWIST_H3_DEBUG journal. Runs on the * engine thread; the last_close_* fields are published to readers that call - * cwist_http3_last_close_error() after the serve loop has stopped. */ + * cwist_http3_last_close_error() after the serve loop has stopped. + */ static void cwist_h3_on_conncloseframe_received(lsquic_conn_t *conn, int app_error, uint64_t error_code, const char *reason, int reason_len) { @@ -804,6 +988,17 @@ static void cwist_h3_on_conncloseframe_received(lsquic_conn_t *conn, int app_err h3_ctx->last_close_received = 1; } +/** + * @brief on_new_stream callback: allocate the per-request stream context. + * @return New stream ctx with an empty cwist_http_request ("HTTP/3"), or + * NULL on OOM (the stream then fails to open). + * + * Server-pushed streams (only possible when push is enabled) are processed + * the same way as client-initiated ones. Per RFC 9218 Section 6 a server + * MUST NOT send PRIORITY_UPDATE for a request stream, so the default + * scheduling priority is kept and lsquic_stream_set_priority() is never + * called here. + */ static lsquic_stream_ctx_t *cwist_h3_on_new_stream(void *stream_if_ctx, lsquic_stream_t *stream) { cwist_http3_context *h3_ctx = (cwist_http3_context *)stream_if_ctx; h3_stream_ctx_t *st = calloc(1, sizeof(*st)); @@ -838,6 +1033,12 @@ static lsquic_stream_ctx_t *cwist_h3_on_new_stream(void *stream_if_ctx, lsquic_s return (lsquic_stream_ctx_t *)st; } +/** + * @brief Split a :path value into req->path and req->query (parsed map). + * + * Allocates the query map in @p req 's arena when a query string is present. + * No query string: the whole value becomes the path. + */ static void h3_parse_path(cwist_http_request *req, const char *path) { const char *q = strchr(path, '?'); if (q) { @@ -856,19 +1057,35 @@ static void h3_parse_path(cwist_http_request *req, const char *path) { } } -/* Lightweight XOR checksum over a byte buffer. */ +/** + * @brief Lightweight XOR checksum over a byte buffer. + * @return XOR of all bytes (0 for an empty buffer). + */ static uint8_t h3_xor_bytes(const unsigned char *buf, size_t len) { uint8_t x = 0; for (size_t i = 0; i < len; i++) x ^= buf[i]; return x; } +/** + * @brief Whether a byte is a valid RFC 9110 field-name character + * (tchar: alphanumerics and !#$%&'*+-.^_`|~). + */ static int h3_header_name_char_is_valid(unsigned char c) { return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '!' || c == '#' || c == '$' || c == '%' || c == '&' || c == '\'' || c == '*' || c == '+' || c == '-' || c == '.' || c == '^' || c == '_' || c == '`' || c == '|' || c == '~'; } +/** + * @brief Normalize a response header name to lowercase HTTP/3 form. + * @param name Source name. + * @param out Output buffer of at least @p out_len bytes. + * @param out_len Capacity of @p out (NUL included). + * @retval 0 Name copied to @p out as lowercase NUL-terminated text. + * @retval -1 NULL/empty input, insufficient capacity, pseudo-header prefix, + * or a character outside the RFC 9110 field-name grammar. + */ int cwist_http3_normalize_response_header_name(const char *name, char *out, size_t out_len) { if (!name || !out || out_len == 0) return -1; @@ -884,6 +1101,13 @@ int cwist_http3_normalize_response_header_name(const char *name, char *out, size return 0; } +/** + * @brief Whether a response header value is safe to put on the wire. + * @retval 1 Every byte satisfies the RFC 9113/9114 field-value grammar + * (no CTLs including CR/LF, no DEL). + * @retval 0 @p value is NULL or contains a forbidden byte; clients would + * reject the response as malformed. + */ int cwist_http3_response_header_value_is_safe(const char *value) { if (!value) return 0; for (const unsigned char *p = (const unsigned char *)value; *p; p++) { @@ -894,6 +1118,15 @@ int cwist_http3_response_header_value_is_safe(const char *value) { return 1; } +/** + * @brief Fold one decoded request header into the cwist_http_request. + * + * Recognized pseudo-headers update method/path/authority; known regular + * headers are stored in the header list; unrecognized pseudo-headers are + * silently ignored (RFC 9114); other regular headers pass through. Also + * detects the WebTransport extended-CONNECT signature (:protocol + * "webtransport" with method CONNECT). + */ static void h3_apply_header(cwist_http_request *req, const char *name, const char *value) { if (strcmp(name, ":method") == 0) { req->method = cwist_http_string_to_method(value); @@ -945,10 +1178,18 @@ static void h3_apply_header(cwist_http_request *req, const char *name, const cha } } -/* HTTP/3 delivers an ordered QUIC byte stream, but an application-level - * sequenced body can be split at arbitrary read boundaries. Buffer just one - * wire chunk, then hand complete chunks to the common TASFA-style ARQ - * assembler. */ +/** + * @brief Append one wire chunk to the sequenced-body buffer and feed every + * complete TASFA chunk to the ARQ assembler. + * @retval 0 Chunk buffered and/or consumed; possibly no complete chunk yet. + * @retval -1 Length overflow, assembler-capacity breach, OOM, or a chunk + * that fails to parse/feed; the caller resets the stream. + * + * HTTP/3 delivers an ordered QUIC byte stream, but an application-level + * sequenced body can be split at arbitrary read boundaries. Only one wire + * chunk is buffered at a time; complete chunks are handed to the common + * TASFA-style ARQ assembler. + */ static int h3_seq_append_and_feed(h3_stream_ctx_t *st, const unsigned char *data, size_t len) { if (len > SIZE_MAX - st->seq_len) return -1; size_t need = st->seq_len + len; @@ -987,10 +1228,13 @@ static int h3_seq_append_and_feed(h3_stream_ctx_t *st, const unsigned char *data return 0; } -/* Reject a request whose header block violates RFC 9114 Section 4.3.1. +/** + * @brief Reject a request whose header block violates RFC 9114 Section 4.3.1. + * * lsquic exposes no stream-reset API in this baseline, so enforcement is a * 400 response followed by closing both stream directions (mirrors the - * existing 413 path), and the request is never dispatched to the handler. */ + * existing 413 path), and the request is never dispatched to the handler. + */ static void h3_reject_malformed_request(lsquic_stream_t *stream, h3_stream_ctx_t *st) { st->malformed = 1; st->headers_done = 1; @@ -1010,6 +1254,18 @@ static void h3_reject_malformed_request(lsquic_stream_t *stream, h3_stream_ctx_t lsquic_stream_wantwrite(stream, 1); } +/** + * @brief Decode the request header set and enforce RFC 9114 Section 4.3.1. + * @retval true Header block complete and valid; st->req is populated and + * st->headers_done is set. Also returns true (idempotently) when + * headers were already processed. + * @retval false Headers not yet available, or the block is malformed and a + * 400 response has been staged (st->malformed). + * + * Checks pseudo-header ordering/uniqueness, the CONNECT vs extended-CONNECT + * completeness rules, mandatory :method/:scheme/:path for normal requests, + * and the OPTIONS-only asterisk-form exception for an empty :path. + */ static bool h3_process_stream_headers(lsquic_stream_t *stream, h3_stream_ctx_t *st) { if (st->headers_done) return true; void *hset = lsquic_stream_get_hset(stream); @@ -1123,8 +1379,10 @@ static bool h3_process_stream_headers(lsquic_stream_t *stream, h3_stream_ctx_t * return true; } -/* RFC 9110: methods with the idempotent property are safe to replay, which - * makes them acceptable for 0-RTT delivery. */ +/** + * @brief Whether a method is idempotent (RFC 9110) and thus acceptable for + * 0-RTT replay. + */ static bool h3_method_is_idempotent(cwist_http_method_t method) { switch (method) { case CWIST_HTTP_GET: @@ -1136,6 +1394,17 @@ static bool h3_method_is_idempotent(cwist_http_method_t method) { } } +/** + * @brief on_read callback: consume the request and dispatch it. + * + * Bodies are accumulated up to CWIST_HTTP_MAX_BODY_SIZE (413 beyond that); + * sequenced bodies go through h3_seq_append_and_feed() and are only + * dispatched when the assembler produces a complete payload. On FIN the + * request handler runs (with the 0-RTT replay guard for non-idempotent + * methods while the handshake is in progress), then the response is armed + * for writing. Malformed requests get an inline 400 without dispatch. + * Fatal read/allocation errors reset the stream. + */ static void cwist_h3_on_read(lsquic_stream_t *stream, lsquic_stream_ctx_t *st_h) { h3_stream_ctx_t *st = (h3_stream_ctx_t *)st_h; if (!st) return; @@ -1343,9 +1612,12 @@ static void cwist_h3_on_read(lsquic_stream_t *stream, lsquic_stream_ctx_t *st_h) } } -/* RFC 9110 Section 8.6: content-length must be a non-empty run of digits. - * Anything else from a handler (whitespace, junk, negative, empty) is - * rejected rather than forwarded into the response headers. */ +/** + * @brief Whether a content-length value is a non-empty run of digits + * (RFC 9110 Section 8.6). Anything else from a handler (whitespace, + * junk, negative, empty) is rejected rather than forwarded into the + * response headers. + */ static bool h3_content_length_is_valid(const char *cl) { if (!cl || cl[0] < '0' || cl[0] > '9') return false; for (const char *p = cl + 1; *p; ++p) { @@ -1354,12 +1626,28 @@ static bool h3_content_length_is_valid(const char *cl) { return true; } -/* RFC 9114 Section 4.1.2: 1xx/204/304 responses carry no content, and - * content-length on them makes the message malformed at connection level. */ +/** + * @brief Whether a status code forbids a response body (RFC 9114 Section + * 4.1.2: 1xx/204/304); content-length on them would make the message + * malformed at connection level. + */ static bool h3_status_forbids_body(int status_code) { return (status_code >= 100 && status_code < 200) || status_code == 204 || status_code == 304; } +/** + * @brief on_write callback: serialize the response (HEADERS, then body). + * + * Three write states: 0 builds and sends the HEADERS block (status coercion + * to 500 for invalid values, content-length computed from the actual body, + * RFC 9114-forbidden connection headers dropped, empty-body responses + * finished with an explicit shutdown(1) because the sendmsg eos flag is + * ignored for IETF QUIC); 1 streams the body from memory, pointer, or file + * (file streaming is chunked a few pread()s per tick so one large response + * cannot starve the other connections); 2 is done and re-arms reading. + * Unusable body sources (NULL pointer body / bad fd with non-zero length) + * reset the stream rather than announce bytes that can never be delivered. + */ static void cwist_h3_on_write(lsquic_stream_t *stream, lsquic_stream_ctx_t *st_h) { h3_stream_ctx_t *st = (h3_stream_ctx_t *)st_h; if (!st || !st->response_ready) return; @@ -1709,6 +1997,10 @@ static void cwist_h3_on_write(lsquic_stream_t *stream, lsquic_stream_ctx_t *st_h } } +/** + * @brief on_close callback: destroy the stream ctx and everything it owns + * (request, response, body buffer, sequenced-data assembler). + */ static void cwist_h3_on_close(lsquic_stream_t *stream, lsquic_stream_ctx_t *st_h) { h3_stream_ctx_t *st = (h3_stream_ctx_t *)st_h; if (st) { @@ -1722,11 +2014,17 @@ static void cwist_h3_on_close(lsquic_stream_t *stream, lsquic_stream_ctx_t *st_h (void)stream; } -/* Pulls the oldest queued datagram off this connection's own queue (see - * h3_conn_ctx_t). Previously this read/freed a single process-wide - * g_h3_dgram struct with no lock: two connections sending datagrams at - * the same time could free() the same buffer twice or hand one - * connection's payload to another. */ +/** + * @brief on_dg_write callback: hand the oldest queued datagram to lsquic. + * @return Bytes copied into @p buf, or 0 when the connection's queue is + * empty or the connection has no wrapper ctx. + * + * Pulls from this connection's own mutex-guarded queue (see h3_conn_ctx_t). + * Previously this read/freed a single process-wide g_h3_dgram struct with no + * lock: two connections sending datagrams at the same time could free() the + * same buffer twice or hand one connection's payload to another. When the + * queue drains, want_datagram_write is disarmed so lsquic stops calling back. + */ static ssize_t cwist_h3_on_dg_write(lsquic_conn_t *conn, void *buf, size_t len) { h3_conn_ctx_t *cc = (h3_conn_ctx_t *)lsquic_conn_get_ctx(conn); if (!cc) return 0; @@ -1752,6 +2050,10 @@ static ssize_t cwist_h3_on_dg_write(lsquic_conn_t *conn, void *buf, size_t len) return (ssize_t)to_copy; } +/** + * @brief on_datagram callback: deliver an incoming H3 datagram to the + * application's registered datagram callback, if any. + */ static void cwist_h3_on_datagram(lsquic_conn_t *conn, const void *buf, size_t len) { cwist_http3_context *ctx = h3_shared_ctx(conn); if (ctx && ctx->datagram_cb) { @@ -1761,6 +2063,15 @@ static void cwist_h3_on_datagram(lsquic_conn_t *conn, const void *buf, size_t le #ifdef CWIST_WEBTRANSPORT +/** + * @brief wti_on_session_open callback: accept an extended-CONNECT request as + * a WebTransport session. + * @return Session ctx (a new session-kind cwist_wt_handle_t), or NULL if the + * stream ctx is unusable or the handle cannot be allocated. + * + * Invokes the application's wt_handler with the request/response pair so it + * can respond and take over the session. + */ static lsquic_wt_session_ctx_t * cwist_h3_wt_on_session_open(void *ctx, lsquic_wt_session_t *sess, const struct lsquic_wt_connect_info *info) { @@ -1779,6 +2090,10 @@ cwist_h3_wt_on_session_open(void *ctx, lsquic_wt_session_t *sess, return NULL; } +/** + * @brief wti_on_session_rejected callback: the peer rejected the session. + * Intentionally a no-op; rejection details are not surfaced to the app. + */ static void cwist_h3_wt_on_session_rejected(void *ctx, const struct lsquic_wt_connect_info *info, unsigned status, const char *reason, size_t reason_len) { @@ -1789,10 +2104,15 @@ static void cwist_h3_wt_on_session_rejected(void *ctx, const struct lsquic_wt_co (void)reason_len; } -/* WebTransport application error codes are 32-bit on the wire. The lsquic +/** + * @brief wti_on_session_close callback: free the session handle (and its + * child handles). + * + * WebTransport application error codes are 32-bit on the wire. The lsquic * WebTransport branch declared these two callbacks with uint64_t when this * code was written; the branch head (litespeedtech/lsquic#629) narrows both - * to uint32_t. */ + * to uint32_t. + */ static void cwist_h3_wt_on_session_close(lsquic_wt_session_t *sess, lsquic_wt_session_ctx_t *sess_ctx, uint32_t code, const char *reason, size_t reason_len) { @@ -1803,6 +2123,15 @@ static void cwist_h3_wt_on_session_close(lsquic_wt_session_t *sess, cwist_wt_handle_free((cwist_wt_handle_t *)sess_ctx); } +/** + * @brief Open-callback helper shared by the uni- and bidi-stream callbacks: + * wrap the stream in a stream-kind handle and notify the app. + * @return Stream ctx (a stream-kind handle), or NULL only if handle + * allocation fails while a new-stream handler is registered. + * + * When no new-stream handler is registered the stream is still wrapped so it + * stays readable; the app can take it over later. + */ static lsquic_stream_ctx_t *cwist_h3_wt_on_stream(lsquic_wt_session_t *sess, lsquic_stream_t *stream) { cwist_http3_context *ctx = NULL; @@ -1823,16 +2152,28 @@ static lsquic_stream_ctx_t *cwist_h3_wt_on_stream(lsquic_wt_session_t *sess, return (lsquic_stream_ctx_t *)cwist_wt_handle_new(CWIST_WT_HANDLE_STREAM, stream); } +/** + * @brief wti_on_uni_stream callback: peer opened a unidirectional stream. + * Delegates to cwist_h3_wt_on_stream(). + */ static lsquic_stream_ctx_t *cwist_h3_wt_on_uni_stream(lsquic_wt_session_t *sess, lsquic_stream_t *stream) { return cwist_h3_wt_on_stream(sess, stream); } +/** + * @brief wti_on_bidi_stream callback: peer opened a bidirectional stream. + * Delegates to cwist_h3_wt_on_stream(). + */ static lsquic_stream_ctx_t *cwist_h3_wt_on_bidi_stream(lsquic_wt_session_t *sess, lsquic_stream_t *stream) { return cwist_h3_wt_on_stream(sess, stream); } +/** + * @brief wti_on_stream_read callback: first data on a WT stream; notifies + * the app's new-stream handler with the stream handle. + */ static void cwist_h3_wt_on_stream_read(lsquic_stream_t *stream, lsquic_stream_ctx_t *st_h) { cwist_wt_handle_t *stream_handle = (cwist_wt_handle_t *)st_h; lsquic_wt_session_t *sess = lsquic_wt_session_from_stream(stream); @@ -1844,22 +2185,37 @@ static void cwist_h3_wt_on_stream_read(lsquic_stream_t *stream, lsquic_stream_ct } } +/** + * @brief wti_on_stream_write callback: nothing to write from the server + * side for WT streams; no-op. + */ static void cwist_h3_wt_on_stream_write(lsquic_stream_t *stream, lsquic_stream_ctx_t *st_h) { (void)stream; (void)st_h; } +/** + * @brief wti_on_stream_close callback: free the stream handle. + */ static void cwist_h3_wt_on_stream_close(lsquic_stream_t *stream, lsquic_stream_ctx_t *st_h) { (void)stream; cwist_wt_handle_free((cwist_wt_handle_t *)st_h); } +/** + * @brief wti_on_stream_ss_code callback: application error code to send when + * this stream is reset. Returns 0 (no specific code). + */ static uint32_t cwist_h3_wt_on_stream_ss_code(lsquic_stream_t *stream, lsquic_stream_ctx_t *st_h) { (void)stream; (void)st_h; return 0; } +/** + * @brief WT datagram-read callback: deliver the payload to the app's + * registered datagram callback, if any. + */ static void cwist_h3_wt_on_datagram_read(lsquic_wt_session_t *sess, const void *buf, size_t len) { lsquic_conn_t *conn = lsquic_wt_session_conn(sess); cwist_http3_context *ctx = h3_shared_ctx(conn); @@ -1868,6 +2224,10 @@ static void cwist_h3_wt_on_datagram_read(lsquic_wt_session_t *sess, const void * } } +/** + * @brief WT datagram-write callback: nothing queued from the server side; + * returns 0 (no datagram to send). + */ static int cwist_h3_wt_on_datagram_write(lsquic_wt_session_t *sess, size_t max_datagram_size) { (void)sess; (void)max_datagram_size; @@ -1923,10 +2283,24 @@ typedef struct cwist_h3_ticket_key { static int g_h3_ticket_key_ex_data_idx = -1; static pthread_once_t g_h3_ticket_key_ex_data_once = PTHREAD_ONCE_INIT; +/** + * @brief pthread_once body: reserve the SSL_CTX ex-data slot that carries + * the per-context ticket key. + */ static void cwist_h3_ticket_key_ex_data_init(void) { g_h3_ticket_key_ex_data_idx = SSL_CTX_get_ex_new_index(0, NULL, NULL, NULL, NULL); } +/** + * @brief BoringSSL session-ticket key callback (shared ticket-key scheme). + * @param encrypt Non-zero when producing a new ticket, zero when resuming. + * @retval 1 Key applied; proceed with the ticket. + * @retval 0 Decline the ticket (unknown key name, missing key, or crypto + * setup failure); a full handshake results. + * + * One random key is generated per cwist_http3_context and installed here, so + * tickets issued by one SO_REUSEPORT worker resume on any other. + */ static int cwist_h3_ticket_key_cb(SSL *ssl, uint8_t *key_name, uint8_t *iv, EVP_CIPHER_CTX *ectx, HMAC_CTX *hctx, int encrypt) { SSL_CTX *ssl_ctx = ssl ? SSL_get_SSL_CTX(ssl) : NULL; @@ -1975,6 +2349,15 @@ static void cwist_h3_setup_session_tickets(SSL_CTX *ssl_ctx) { SSL_CTX_set_tlsext_ticket_key_cb(ssl_ctx, cwist_h3_ticket_key_cb); } +/** + * @brief Free the per-context session-ticket key, if one was installed. + * @param ssl_ctx Context whose ex-data slot was set by + * cwist_h3_setup_session_tickets(); may be NULL (no-op). + * + * Thread-safety: takes the pthread_once gate before reading + * g_h3_ticket_key_ex_data_idx because this can run on a thread that never + * armed the once itself (see the note in the body). + */ static void cwist_h3_free_session_ticket_key(SSL_CTX *ssl_ctx) { if (!ssl_ctx) return; /* g_h3_ticket_key_ex_data_idx is only written once, from inside @@ -1997,6 +2380,15 @@ static void cwist_h3_free_session_ticket_key(SSL_CTX *ssl_ctx) { } } +/** + * @brief Apply the common server-side SSL_CTX configuration. + * @param early_data Non-zero to enable TLS 1.3 early data (0-RTT). + * @return Always 0. QUIC transport parameters are supplied by lsquic, not + * here. + * + * Pins TLS 1.3, installs default verify paths, the ALPN selection callback, + * and the shared session-ticket key machinery. + */ static int cwist_h3_ssl_ctx_init(SSL_CTX *ssl_ctx, int early_data) { SSL_CTX_set_min_proto_version(ssl_ctx, TLS1_3_VERSION); SSL_CTX_set_max_proto_version(ssl_ctx, TLS1_3_VERSION); @@ -2012,6 +2404,18 @@ static int cwist_h3_ssl_ctx_init(SSL_CTX *ssl_ctx, int early_data) { return 0; } +/** + * @brief Create an HTTP/3 server context from a certificate/key pair. + * @param ctx Receives the new context. + * @param cert_path PEM file with the certificate chain. + * @param key_path PEM file with the private key. + * @return err.error.err_i16 == 0 on success; -1 on bad arguments, TLS setup + * failure, or OOM. On failure all partial state is unwound and + * *ctx is untouched. + * + * Takes a process-wide lsquic reference (released by + * cwist_http3_destroy_context()). + */ cwist_error_t cwist_http3_init_context(cwist_http3_context **ctx, const char *cert_path, const char *key_path) { cwist_error_t err = make_error(CWIST_ERR_INT16); @@ -2073,6 +2477,17 @@ cwist_error_t cwist_http3_init_context(cwist_http3_context **ctx, const char *ce return err; } +/** + * @brief Create an HTTP/3 server context with an ephemeral self-signed + * certificate ("localhost", RSA-2048, 1-year validity). + * @param ctx Receives the new context. + * @return err.error.err_i16 == 0 on success; -1 on TLS setup failure or OOM. + * On failure all partial state is unwound. + * + * Intended for tests and local deployments where provisioning a real + * certificate is impractical. Takes a process-wide lsquic reference + * (released by cwist_http3_destroy_context()). + */ cwist_error_t cwist_http3_init_context_ephemeral(cwist_http3_context **ctx) { cwist_error_t err = make_error(CWIST_ERR_INT16); if (!ctx) { @@ -2169,12 +2584,16 @@ cwist_error_t cwist_http3_init_context_ephemeral(cwist_http3_context **ctx) { return err; } -/* Graceful shutdown: send GOAWAY on every live connection (and stop - * accepting new ones), then give the engine one chance to flush the queued - * packets before teardown. lsquic_engine_cooldown() marks full - * connections going away (GOAWAY per RFC 9114 Section 5.2); the ensuing - * lsquic_engine_destroy() closes them with CONNECTION_CLOSE/H3_NO_ERROR - * instead of leaving clients hanging on silence. */ +/** + * @brief Graceful shutdown: send GOAWAY on every live connection (and stop + * accepting new ones), then give the engine one chance to flush the + * queued packets before teardown. + * + * lsquic_engine_cooldown() marks full connections going away (GOAWAY per + * RFC 9114 Section 5.2); the ensuing lsquic_engine_destroy() closes them with + * CONNECTION_CLOSE/H3_NO_ERROR instead of leaving clients hanging on + * silence. No-op when the engine was never created. + */ static void cwist_h3_engine_graceful_stop(cwist_http3_context *ctx) { lsquic_engine_t *engine = (lsquic_engine_t *)ctx->engine; if (!engine) return; @@ -2185,6 +2604,14 @@ static void cwist_h3_engine_graceful_stop(cwist_http3_context *ctx) { } } +/** + * @brief Destroy a context: graceful engine stop, engine destroy, hset + * sweep, SSL_CTX and ticket-key teardown, context free. + * @param ctx Context from cwist_http3_init_context*(); may be NULL (no-op). + * + * Releases the process-wide lsquic reference taken at init. Must not be + * called concurrently with cwist_http3_server_loop() on the same context. + */ void cwist_http3_destroy_context(cwist_http3_context *ctx) { if (ctx) { if (ctx->engine) { @@ -2203,6 +2630,20 @@ void cwist_http3_destroy_context(cwist_http3_context *ctx) { } } +/** + * @brief Return the last CONNECTION_CLOSE received from a peer. + * @param app_error_out Receives whether the close carried an application + * error (may be NULL). + * @param code_out Receives the error code (may be NULL). + * @param reason_buf Receives the NUL-terminated reason text, truncated to + * fit (may be NULL). + * @param buf_len Capacity of @p reason_buf. + * @retval 0 A close frame has been recorded and the outputs were filled. + * @retval -1 No CONNECTION_CLOSE has been received (or @p ctx is NULL). + * + * Meaningful only after the serve loop has stopped; the fields are written + * on the engine thread without synchronization. + */ int cwist_http3_last_close_error(const cwist_http3_context *ctx, bool *app_error_out, uint64_t *code_out, char *reason_buf, size_t buf_len) { if (!ctx || !ctx->last_close_received) return -1; @@ -2221,6 +2662,25 @@ int cwist_http3_last_close_error(const cwist_http3_context *ctx, bool *app_error /* Server loop */ /* ------------------------------------------------------------------ */ +/** + * @brief Run the blocking HTTP/3 server loop on a UDP socket. + * @param udp_fd Bound UDP socket; forced non-blocking here (idempotent). + * @param ctx Context with an initialized SSL_CTX. + * @param handler Request handler invoked per request (required). + * @param user_ctx Passed through to @p handler. + * @return err.error.err_i16 == 0 when the loop exits cleanly (ctx->running + * cleared or process shutdown); -1 on invalid arguments or setup + * failure (socket flags, epoll, memory). + * + * Configures lsquic engine settings from the context's knobs (push, + * WebTransport, datagrams, flow-control windows, timeout set), then polls + * the socket (epoll on Linux, poll elsewhere; recvmmsg batching when + * available), feeds packets to the engine, and processes connections until + * shutdown. Linux receive batches and the packet buffer are heap-allocated + * so the loop does not require an oversized thread stack. On exit the + * engine is gracefully stopped and destroyed. CWIST_H3_DEBUG=1 routes + * lsquic's logger to stderr. + */ cwist_error_t cwist_http3_server_loop(int udp_fd, cwist_http3_context *ctx, cwist_http3_request_handler_func handler, void *user_ctx) { cwist_error_t err = make_error(CWIST_ERR_INT16); @@ -2680,10 +3140,21 @@ cwist_error_t cwist_http3_server_loop(int udp_fd, cwist_http3_context *ctx, /* Push, Priority, and 0-RTT APIs */ /* ------------------------------------------------------------------ */ +/** + * @brief Enable or disable HTTP/3 server push for this context. + * Takes effect at cwist_http3_server_loop() time (lsquic engine setting). + */ void cwist_http3_set_push_enabled(cwist_http3_context *ctx, int enabled) { if (ctx) ctx->push_enabled = enabled; } +/** + * @brief Enable or disable TLS 1.3 early data (0-RTT) on the context. + * + * Enabling also arms the replay guard (only idempotent methods are accepted + * as early data) unless the application overrides it with + * cwist_http3_set_early_data_guard(). + */ void cwist_http3_set_early_data(cwist_http3_context *ctx, bool enabled) { if (!ctx || !ctx->ssl_ctx) return; ctx->early_data_enabled = enabled ? 1 : 0; @@ -2695,11 +3166,23 @@ void cwist_http3_set_early_data(cwist_http3_context *ctx, bool enabled) { } } +/** + * @brief Enable or disable the 0-RTT replay guard. + * + * While armed, requests received while the QUIC handshake is still in + * progress are refused with 425 unless their method is idempotent (RFC + * 8470). + */ void cwist_http3_set_early_data_guard(cwist_http3_context *ctx, int enabled) { if (ctx) ctx->early_data_guard = enabled ? 1 : 0; } -/* RFC 9110: string-based variant used by tests and external checks. */ +/** + * @brief String-based idempotency check (RFC 9110), used by tests and + * external checks. + * @retval 1 The method is idempotent (GET/HEAD/PUT/DELETE/OPTIONS/TRACE). + * @retval 0 NULL input or a non-idempotent/unknown method. + */ int cwist_http3_method_is_idempotent(const char *method_str) { if (!method_str) return 0; /* TRACE is absent from cwist_http_method_t; match it by name. */ @@ -2707,6 +3190,20 @@ int cwist_http3_method_is_idempotent(const char *method_str) { return h3_method_is_idempotent(cwist_http_string_to_method(method_str)) ? 1 : 0; } +/** + * @brief Promise a pushed resource from inside a request handler. + * @param req Request whose stream anchors the push (must carry the lsquic + * stream in private_data). + * @param path Request path for the pushed resource. + * @param content_type Ignored; a response field, not valid in a push + * request set. + * @retval 0 Push stream created. + * @retval -1 Bad arguments, no connection, or push not negotiated. + * + * Builds a request header set (:method GET, :scheme https, :path from + * @p path, :authority copied from the original request's Host or + * "localhost"). + */ int cwist_http3_push_resource(cwist_http_request *req, const char *path, const char *content_type) { if (!req || !req->private_data || !path) return -1; lsquic_stream_t *stream = (lsquic_stream_t *)req->private_data; @@ -2773,13 +3270,17 @@ int cwist_http3_push_resource(cwist_http_request *req, const char *path, const c return lsquic_conn_push_stream(conn, NULL, stream, &headers); } +/** + * @brief Deprecated and always refused: returns -1. + * + * lsquic_stream_set_priority() on a request stream emits a PRIORITY_UPDATE + * frame on the control stream, which violates RFC 9218 (PRIORITY_UPDATE may + * only reference client-opened streams). Strict stacks (Firefox/neqo) then + * kill the connection with H3_FRAME_UNEXPECTED. Use the RFC 9218 `Priority` + * header instead. See the deprecation note in + * include/cwist/net/http/http3.h. + */ int cwist_http3_set_stream_priority(cwist_http_request *req, unsigned priority) { - /* Refused on purpose: lsquic_stream_set_priority() on a request stream - * emits a PRIORITY_UPDATE frame on the control stream, which violates - * RFC 9218 (PRIORITY_UPDATE may only reference client-opened streams). - * Strict stacks (Firefox/neqo) then kill the connection with - * H3_FRAME_UNEXPECTED. Use the RFC 9218 `Priority` header instead. - * See the deprecation note in include/cwist/net/http/http3.h. */ (void)req; (void)priority; CWIST_LOG_WARN("[HTTP/3] cwist_http3_set_stream_priority() is deprecated " @@ -2791,11 +3292,22 @@ int cwist_http3_set_stream_priority(cwist_http_request *req, unsigned priority) /* WebTransport API */ /* ------------------------------------------------------------------ */ +/** + * @brief Register the WebTransport session handler (extended CONNECT). + * + * When set, the server loop enables the lsquic WebTransport settings and + * extended-CONNECT requests with :protocol "webtransport" are handed to this + * handler via cwist_h3_wt_on_session_open(). + */ void cwist_http3_set_webtransport_handler(cwist_http3_context *ctx, cwist_webtransport_handler_func handler) { if (ctx) ctx->wt_handler = handler; } +/** + * @brief Register the callback invoked for every new WebTransport stream. + * @param user_ctx Passed through as the second argument of @p handler. + */ void cwist_webtransport_set_new_stream_handler(cwist_http3_context *ctx, void (*handler)(void *stream, void *user_ctx), void *user_ctx) { @@ -2807,6 +3319,11 @@ void cwist_webtransport_set_new_stream_handler(cwist_http3_context *ctx, #ifdef CWIST_WEBTRANSPORT +/** + * @brief Read from a WebTransport stream (CWIST_WEBTRANSPORT build). + * @param stream Stream handle (opaque cwist_wt_handle_t). + * @return Bytes read, or -1 on bad arguments/invalid handle. + */ ssize_t cwist_webtransport_read(void *stream, void *buf, size_t len) { if (!stream || !buf) return -1; lsquic_stream_t *s = cwist_wt_handle_stream(stream); @@ -2814,6 +3331,11 @@ ssize_t cwist_webtransport_read(void *stream, void *buf, size_t len) { return lsquic_stream_read(s, buf, len); } +/** + * @brief Write to a WebTransport stream (CWIST_WEBTRANSPORT build). + * @param stream Stream handle (opaque cwist_wt_handle_t). + * @return Bytes written, or -1 on bad arguments/invalid handle. + */ ssize_t cwist_webtransport_write(void *stream, const void *data, size_t len) { if (!stream || !data) return -1; lsquic_stream_t *s = cwist_wt_handle_stream(stream); @@ -2821,6 +3343,11 @@ ssize_t cwist_webtransport_write(void *stream, const void *data, size_t len) { return lsquic_stream_write(s, data, len); } +/** + * @brief Flush a WebTransport stream (CWIST_WEBTRANSPORT build). + * @retval 0 Flushed. + * @retval -1 Bad arguments or invalid handle. + */ int cwist_webtransport_flush(void *stream) { if (!stream) return -1; lsquic_stream_t *s = cwist_wt_handle_stream(stream); @@ -2828,6 +3355,11 @@ int cwist_webtransport_flush(void *stream) { return lsquic_stream_flush(s); } +/** + * @brief Close a WebTransport stream (CWIST_WEBTRANSPORT build). + * @retval 0 Closed. + * @retval -1 Bad arguments or invalid handle. + */ int cwist_webtransport_close_stream(void *stream) { if (!stream) return -1; lsquic_stream_t *s = cwist_wt_handle_stream(stream); @@ -2835,6 +3367,15 @@ int cwist_webtransport_close_stream(void *stream) { return lsquic_stream_close(s); } +/** + * @brief Open a bidirectional WebTransport stream (CWIST_WEBTRANSPORT + * build). + * @param session Session handle (opaque cwist_wt_handle_t). + * @retval 0 Stream opened (and reported to the new-stream handler when one + * is registered). + * @retval -1 Bad arguments, invalid session, stream open failure, or handle + * allocation failure. + */ int cwist_webtransport_open_bidi_stream(void *session) { if (!session) return -1; cwist_wt_handle_t *session_handle = cwist_wt_handle_cast(session, CWIST_WT_HANDLE_SESSION); @@ -2857,6 +3398,15 @@ int cwist_webtransport_open_bidi_stream(void *session) { return 0; } +/** + * @brief Open a unidirectional WebTransport stream (CWIST_WEBTRANSPORT + * build). + * @param session Session handle (opaque cwist_wt_handle_t). + * @retval 0 Stream opened (and reported to the new-stream handler when one + * is registered). + * @retval -1 Bad arguments, invalid session, stream open failure, or handle + * allocation failure. + */ int cwist_webtransport_open_uni_stream(void *session) { if (!session) return -1; cwist_wt_handle_t *session_handle = cwist_wt_handle_cast(session, CWIST_WT_HANDLE_SESSION); @@ -2881,6 +3431,9 @@ int cwist_webtransport_open_uni_stream(void *session) { #else /* CWIST_WEBTRANSPORT */ +/** + * @brief Stub used when CWIST_WEBTRANSPORT is not compiled in: always -1. + */ ssize_t cwist_webtransport_read(void *stream, void *buf, size_t len) { (void)stream; (void)buf; @@ -2888,6 +3441,9 @@ ssize_t cwist_webtransport_read(void *stream, void *buf, size_t len) { return -1; } +/** + * @brief Stub used when CWIST_WEBTRANSPORT is not compiled in: always -1. + */ ssize_t cwist_webtransport_write(void *stream, const void *data, size_t len) { (void)stream; (void)data; @@ -2895,21 +3451,33 @@ ssize_t cwist_webtransport_write(void *stream, const void *data, size_t len) { return -1; } +/** + * @brief Stub used when CWIST_WEBTRANSPORT is not compiled in: always -1. + */ int cwist_webtransport_flush(void *stream) { (void)stream; return -1; } +/** + * @brief Stub used when CWIST_WEBTRANSPORT is not compiled in: always -1. + */ int cwist_webtransport_close_stream(void *stream) { (void)stream; return -1; } +/** + * @brief Stub used when CWIST_WEBTRANSPORT is not compiled in: always -1. + */ int cwist_webtransport_open_bidi_stream(void *session) { (void)session; return -1; } +/** + * @brief Stub used when CWIST_WEBTRANSPORT is not compiled in: always -1. + */ int cwist_webtransport_open_uni_stream(void *session) { (void)session; return -1; @@ -2921,18 +3489,34 @@ int cwist_webtransport_open_uni_stream(void *session) { /* Resilience knobs */ /* ------------------------------------------------------------------ */ +/** + * @brief Set the connection idle timeout (milliseconds, <= 0 disables). + * Takes effect at cwist_http3_server_loop() time. + */ void cwist_http3_set_idle_timeout(cwist_http3_context *ctx, int ms) { if (ctx) ctx->idle_timeout_ms = ms > 0 ? ms : 0; } +/** + * @brief Set the handshake timeout (milliseconds, <= 0 disables). + * Takes effect at cwist_http3_server_loop() time. + */ void cwist_http3_set_handshake_timeout(cwist_http3_context *ctx, int ms) { if (ctx) ctx->handshake_timeout_ms = ms > 0 ? ms : 0; } +/** + * @brief Set the keep-alive ping period (milliseconds, <= 0 disables). + * Takes effect at cwist_http3_server_loop() time. + */ void cwist_http3_set_ping_period(cwist_http3_context *ctx, int ms) { if (ctx) ctx->ping_period_ms = ms > 0 ? ms : 0; } +/** + * @brief Set the no-progress timeout (milliseconds, <= 0 disables). + * Takes effect at cwist_http3_server_loop() time. + */ void cwist_http3_set_noprogress_timeout(cwist_http3_context *ctx, int ms) { if (ctx) ctx->noprogress_timeout_ms = ms > 0 ? ms : 0; } @@ -2941,10 +3525,20 @@ void cwist_http3_set_noprogress_timeout(cwist_http3_context *ctx, int ms) { /* Datagram API */ /* ------------------------------------------------------------------ */ +/** + * @brief Enable or disable H3 datagrams for this context. + * + * Note: enabling WebTransport forces the datagram engine settings on + * regardless of this knob. + */ void cwist_http3_set_datagram_enabled(cwist_http3_context *ctx, int enabled) { if (ctx) ctx->datagram_enabled = enabled; } +/** + * @brief Register the callback for incoming H3/WebTransport datagrams. + * @param user_ctx Passed through as the last argument of @p cb. + */ void cwist_http3_set_datagram_callback(cwist_http3_context *ctx, void (*cb)(const void *data, size_t len, void *user_ctx), void *user_ctx) { @@ -2954,6 +3548,21 @@ void cwist_http3_set_datagram_callback(cwist_http3_context *ctx, } } +/** + * @brief Queue an outgoing H3 datagram on a connection. + * @param conn lsquic connection (as seen in cwist_http_request::private_data + * chains); used to find the per-connection queue. + * @param data Payload to copy. + * @param len Payload length. + * @retval 0 Datagram queued; lsquic is signaled to drain it via the + * on_dg_write callback. + * @retval -1 Bad arguments, no per-connection ctx, or OOM (nothing queued). + * + * Thread-safe: the payload is copied and appended to the connection's own + * mutex-guarded queue, so concurrent sends on different connections never + * share state and concurrent sends on one connection queue instead of + * clobbering each other. + */ int cwist_http3_send_datagram(void *conn, const void *data, size_t len) { lsquic_conn_t *c = (lsquic_conn_t *)conn; if (!c || !data || len == 0) return -1; @@ -2991,6 +3600,13 @@ int cwist_http3_send_datagram(void *conn, const void *data, size_t len) { #ifdef CWIST_WEBTRANSPORT +/** + * @brief Send a datagram on a WebTransport session (CWIST_WEBTRANSPORT + * build). + * @param session Session handle (opaque cwist_wt_handle_t). + * @return lsquic_wt_send_datagram() result, or -1 on bad arguments/invalid + * handle. + */ ssize_t cwist_webtransport_send_datagram(void *session, const void *data, size_t len) { if (!session || !data || len == 0) return -1; lsquic_wt_session_t *sess = cwist_wt_handle_session(session); @@ -2998,6 +3614,12 @@ ssize_t cwist_webtransport_send_datagram(void *session, const void *data, size_t return lsquic_wt_send_datagram(sess, data, len); } +/** + * @brief Maximum datagram size on a WebTransport session + * (CWIST_WEBTRANSPORT build). + * @param session Session handle (opaque cwist_wt_handle_t). + * @return Maximum payload size, or 0 on bad arguments/invalid handle. + */ size_t cwist_webtransport_max_datagram_size(void *session) { if (!session) return 0; lsquic_wt_session_t *sess = cwist_wt_handle_session(session); @@ -3005,6 +3627,15 @@ size_t cwist_webtransport_max_datagram_size(void *session) { return lsquic_wt_max_datagram_size(sess); } +/** + * @brief Close a WebTransport session with an application error code + * (CWIST_WEBTRANSPORT build). + * @param session Session handle (opaque cwist_wt_handle_t). + * @param code 32-bit WebTransport application error code. + * @param reason Human-readable reason; may be NULL. + * @retval 0 Close frame sent. + * @retval -1 Bad arguments or invalid handle. + */ int cwist_webtransport_close_session(void *session, uint64_t code, const char *reason) { if (!session) return -1; lsquic_wt_session_t *sess = cwist_wt_handle_session(session); @@ -3014,6 +3645,9 @@ int cwist_webtransport_close_session(void *session, uint64_t code, const char *r #else /* CWIST_WEBTRANSPORT */ +/** + * @brief Stub used when CWIST_WEBTRANSPORT is not compiled in: always -1. + */ ssize_t cwist_webtransport_send_datagram(void *session, const void *data, size_t len) { (void)session; (void)data; @@ -3021,11 +3655,17 @@ ssize_t cwist_webtransport_send_datagram(void *session, const void *data, size_t return -1; } +/** + * @brief Stub used when CWIST_WEBTRANSPORT is not compiled in: always 0. + */ size_t cwist_webtransport_max_datagram_size(void *session) { (void)session; return 0; } +/** + * @brief Stub used when CWIST_WEBTRANSPORT is not compiled in: always -1. + */ int cwist_webtransport_close_session(void *session, uint64_t code, const char *reason) { (void)session; (void)code; diff --git a/src/net/http/http3_client.c b/src/net/http/http3_client.c index efceadd37..dc6e711a1 100644 --- a/src/net/http/http3_client.c +++ b/src/net/http/http3_client.c @@ -53,6 +53,11 @@ static pthread_mutex_t g_h3c_global_mtx = PTHREAD_MUTEX_INITIALIZER; static int g_h3c_global_ref = 0; +/** + * @brief Initialize lsquic global client state on first use. + * Thread-safe via g_h3c_global_mtx; reference-counted so paired calls with + * h3c_global_cleanup() nest correctly. + */ static void h3c_global_init(void) { pthread_mutex_lock(&g_h3c_global_mtx); if (g_h3c_global_ref == 0) { @@ -62,6 +67,11 @@ static void h3c_global_init(void) { pthread_mutex_unlock(&g_h3c_global_mtx); } +/** + * @brief Release one reference to lsquic global client state. + * Runs lsquic_global_cleanup() when the last reference is dropped. + * Safe to call more often than h3c_global_init(). + */ static void h3c_global_cleanup(void) { pthread_mutex_lock(&g_h3c_global_mtx); if (g_h3c_global_ref > 0) { @@ -147,6 +157,18 @@ struct cwist_http3_client { /* Packet-out callback */ /* ------------------------------------------------------------------ */ +/** + * @brief lsquic packets-out callback: send queued QUIC packets via UDP. + * @param ctx Client handle (ea_packets_out_ctx). + * @param specs Array of output specs; each carries iovecs and a destination. + * @param n_specs Number of entries in @p specs. + * @return Number of specs consumed (send progress), or -1 on a send error + * other than would-block. + * @retval -1 A sendmsg() error other than EAGAIN/EWOULDBLOCK occurred. + * + * The UDP socket is connected once the peer is known, so the destination + * address is only attached while the socket is still unconnected. + */ static int h3c_packets_out(void *ctx, const struct lsquic_out_spec *specs, unsigned n_specs) { cwist_http3_client *client = ctx; unsigned i; @@ -187,6 +209,11 @@ typedef struct h3c_hset { size_t decode_off; } h3c_hset_t; +/** + * @brief lsquic header-set callback: allocate a new header set for a stream. + * @return Newly calloc'ed h3c_hset_t, or NULL on allocation failure + * (ownership passes to lsquic and later h3c_hsi_discard()). + */ static void *h3c_hsi_create(void *hsi_ctx, lsquic_stream_t *stream, int is_push_promise) { (void)hsi_ctx; (void)stream; @@ -195,6 +222,18 @@ static void *h3c_hsi_create(void *hsi_ctx, lsquic_stream_t *stream, int is_push_ return hset; } +/** + * @brief lsquic header-set callback: reserve decode space for one header. + * @param hset_p h3c_hset_t allocated by h3c_hsi_create(). + * @param xhdr Non-NULL when lsquic retries the same header with a larger + * required value space; the decoded name must still fit. + * @param req_space Required value capacity in bytes. + * @return Prepared lsxpack_header slot, or NULL if the header set is full or + * the requested space does not fit in the shared decode buffer. + * + * Header name/value slices are carved out of a single 64 KiB decode buffer + * shared by all headers in the set. + */ static struct lsxpack_header *h3c_hsi_prepare(void *hset_p, struct lsxpack_header *xhdr, size_t req_space) { h3c_hset_t *hset = hset_p; @@ -220,6 +259,12 @@ static struct lsxpack_header *h3c_hsi_prepare(void *hset_p, struct lsxpack_heade return &hset->headers[hset->count]; } +/** + * @brief lsquic header-set callback: commit a fully decoded header. + * @return 0 on success; -1 if the decoded header no longer fits the decode + * buffer. + * A NULL @p xhdr marks the end of a header block and is accepted as no-op. + */ static int h3c_hsi_process_header(void *hset_p, struct lsxpack_header *xhdr) { h3c_hset_t *hset = hset_p; /* A NULL header marks the end of a header block. */ @@ -234,6 +279,11 @@ static int h3c_hsi_process_header(void *hset_p, struct lsxpack_header *xhdr) { return 0; } +/** + * @brief lsquic header-set callback: free a header set. + * Frees the h3c_hset_t allocated by h3c_hsi_create(), including its + * embedded header array and decode buffer. + */ static void h3c_hsi_discard(void *hset_p) { free(hset_p); } @@ -249,22 +299,41 @@ static const struct lsquic_hset_if h3c_hset_if = { /* Stream callbacks */ /* ------------------------------------------------------------------ */ +/** + * @brief lsquic stream-iface callback: new connection established. + * @return The client handle as connection context, so later callbacks can + * recover it via lsquic_conn_get_ctx(). + */ static lsquic_conn_ctx_t *h3c_on_new_conn(void *stream_if_ctx, lsquic_conn_t *conn) { cwist_http3_client *client = stream_if_ctx; (void)conn; return (lsquic_conn_ctx_t *)client; } +/** + * @brief lsquic stream-iface callback: connection is being destroyed. + * + * The engine destroys the connection after this callback; clears the cached + * client->conn pointer so a later request never dereferences freed memory + * (e.g. after a certificate verification failure). + */ static void h3c_on_conn_closed(lsquic_conn_t *conn) { - /* The engine destroys the connection after this callback; drop our - * cached pointer so a later request never dereferences freed memory - * (e.g. after a certificate verification failure). */ cwist_http3_client *client = (cwist_http3_client *)lsquic_conn_get_ctx(conn); if (client && client->conn == conn) { client->conn = NULL; } } +/** + * @brief lsquic stream-iface callback: a new stream was created. + * @return Newly calloc'ed h3c_stream_ctx_t, or NULL on allocation failure + * (ownership passes to lsquic and later h3c_on_close()). + * + * Registers the stream as client->active_stream under client->mtx. Does not + * arm the write side: the request function populates path/method/headers/ + * body first and enables wantwrite() afterwards, so no default "GET /" is + * ever emitted. + */ static lsquic_stream_ctx_t *h3c_on_new_stream(void *stream_if_ctx, lsquic_stream_t *stream) { cwist_http3_client *client = stream_if_ctx; h3c_stream_ctx_t *st = calloc(1, sizeof(*st)); @@ -282,6 +351,13 @@ static lsquic_stream_ctx_t *h3c_on_new_stream(void *stream_if_ctx, lsquic_stream } #ifdef CWIST_WEBTRANSPORT +/** + * @brief WebTransport callback: session opened after a successful CONNECT. + * @return The cwist_webtransport_client_session as session context. + * + * Records the native session, marks the session open, and wakes any waiter + * blocked in cwist_http3_client_request(). + */ static lsquic_wt_session_ctx_t *h3c_wt_on_session_open(void *ctx, lsquic_wt_session_t *native, const struct lsquic_wt_connect_info *info) { (void)info; @@ -296,6 +372,12 @@ static lsquic_wt_session_ctx_t *h3c_wt_on_session_open(void *ctx, lsquic_wt_sess return (lsquic_wt_session_ctx_t *)client->wt_connecting; } +/** + * @brief WebTransport callback: session establishment was rejected. + * + * Marks the pending session as rejected and wakes waiters so the CONNECT + * request completes with an error. + */ static void h3c_wt_on_session_rejected(void *ctx, const struct lsquic_wt_connect_info *info, unsigned status, const char *reason, size_t reason_len) { (void)info; @@ -311,11 +393,18 @@ static void h3c_wt_on_session_rejected(void *ctx, const struct lsquic_wt_connect pthread_mutex_unlock(&client->mtx); } -static void -/* wti_on_session_close takes the WebTransport application error code, which +/** + * @brief WebTransport callback: session closed by the peer or engine. + * + * Marks the session closed and drops the cached native session pointer; the + * session context itself is owned by cwist_webtransport_client_close(). + * + * wti_on_session_close takes the WebTransport application error code, which * is 32-bit on the wire. It was uint64_t in the older state of the lsquic * WebTransport branch this file was first written against; the branch head - * (litespeedtech/lsquic#629) narrows it to uint32_t. */ + * (litespeedtech/lsquic#629) narrows it to uint32_t. + */ +static void h3c_wt_on_session_close(lsquic_wt_session_t *native, lsquic_wt_session_ctx_t *ctx, uint32_t code, const char *reason, size_t reason_len) { (void)native; @@ -336,6 +425,21 @@ static const struct lsquic_webtransport_if h3c_wt_if = { }; #endif +/** + * @brief lsquic stream read callback: decode response headers and body. + * + * On first invocation, converts the received header set into a + * cwist_http_response_t (status code and header list) and releases the + * header set. When WebTransport is enabled and this is an extended-connect + * stream, a 2xx status is promoted to a WebTransport session via + * lsquic_wt_accept(); anything else marks the request rejected. Reads the + * body incrementally into a growing buffer; on end-of-stream the body is + * attached to the response and @p st_h ->response_ready is set to unblock + * the waiting request thread. + * + * @param stream The lsquic stream data arrived on. + * @param st_h Per-stream context (h3c_stream_ctx_t). + */ static void h3c_on_read(lsquic_stream_t *stream, lsquic_stream_ctx_t *st_h) { h3c_stream_ctx_t *st = (h3c_stream_ctx_t *)st_h; if (!st) return; @@ -440,6 +544,18 @@ static void h3c_on_read(lsquic_stream_t *stream, lsquic_stream_ctx_t *st_h) { } } +/** + * @brief lsquic stream write callback: serialize and send the request. + * + * Builds the QPACK pseudo-headers (:method, :path, :scheme, :authority and + * optional extra headers) plus the request body, and hands them to lsquic. + * Afterwards the write side is shut down (FIN) and the stream switches to + * read mode unless the request expects an interim response. No-op once + * @p st_h ->write_done is set. + * + * @param stream The lsquic stream to write the request on. + * @param st_h Per-stream context (h3c_stream_ctx_t). + */ static void h3c_on_write(lsquic_stream_t *stream, lsquic_stream_ctx_t *st_h) { h3c_stream_ctx_t *st = (h3c_stream_ctx_t *)st_h; if (!st || st->write_done) return; @@ -561,6 +677,17 @@ static void h3c_on_write(lsquic_stream_t *stream, lsquic_stream_ctx_t *st_h) { } } +/** + * @brief lsquic stream close callback: complete the request and free state. + * + * Signals @p st_h ->response_ready (if the response had not completed yet), + * detaches the stream from the client's active_stream pointer, wakes any + * waiter on the client condition variable, and releases the per-stream + * request/response buffers and the context itself. + * + * @param stream The lsquic stream being closed. + * @param st_h Per-stream context (h3c_stream_ctx_t); may be NULL. + */ static void h3c_on_close(lsquic_stream_t *stream, lsquic_stream_ctx_t *st_h) { h3c_stream_ctx_t *st = (h3c_stream_ctx_t *)st_h; if (!st) return; @@ -579,6 +706,17 @@ static void h3c_on_close(lsquic_stream_t *stream, lsquic_stream_ctx_t *st_h) { free(st); } +/** + * @brief lsquic datagram-write callback: emit one pending outgoing datagram. + * + * Copies the client-held pending datagram (protected by dgram_mtx) into + * lsquic's buffer, frees the stored copy, and clears the pending flag. + * + * @param conn The lsquic connection asking for a datagram. + * @param buf Destination buffer provided by lsquic. + * @param len Capacity of @p buf in bytes. + * @return Number of bytes written to @p buf, or 0 if nothing is pending. + */ static ssize_t h3c_on_dg_write(lsquic_conn_t *conn, void *buf, size_t len) { cwist_http3_client *client = (cwist_http3_client *)lsquic_conn_get_ctx(conn); if (!client) return 0; @@ -597,6 +735,18 @@ static ssize_t h3c_on_dg_write(lsquic_conn_t *conn, void *buf, size_t len) { return 0; } +/** + * @brief lsquic datagram-received callback: store one incoming datagram. + * + * Takes ownership of the datagram payload by copying it into the client's + * in_dgram slot (protected by dgram_mtx), replacing any previously received + * datagram that was not yet consumed, and sets the ready flag so the + * receiving thread can pick it up. + * + * @param conn The lsquic connection the datagram arrived on. + * @param buf Pointer to the datagram payload. + * @param len Payload length in bytes. + */ static void h3c_on_datagram(lsquic_conn_t *conn, const void *buf, size_t len) { cwist_http3_client *client = (cwist_http3_client *)lsquic_conn_get_ctx(conn); if (!client || !buf || len == 0) return; @@ -626,6 +776,18 @@ static const struct lsquic_stream_if h3c_stream_if = { /* I/O loop helper */ /* ------------------------------------------------------------------ */ +/** + * @brief Run one iteration of the QUIC I/O loop. + * + * Computes how long lsquic wants to wait until its next timer (bounded to + * [0, 1s]), polls the UDP socket for that interval, feeds any received + * datagram into lsquic_engine_packet_in(), and finally runs + * lsquic_engine_process_conns() to advance all connections. + * + * @param client The HTTP/3 client whose engine and socket are polled. + * @param timeout_ms Desired poll timeout in milliseconds; values <= 0 + * fall back to 1 ms. + */ static void h3c_process_io(cwist_http3_client *client, int timeout_ms) { lsquic_engine_t *engine = client->engine; int diff = timeout_ms * 1000; /* microseconds */ @@ -658,11 +820,16 @@ static void h3c_process_io(cwist_http3_client *client, int timeout_ms) { lsquic_engine_process_conns(engine); } -/* ------------------------------------------------------------------ */ -/* SSL context callback: hand lsquic the client SSL_CTX so the */ -/* handshake honors its trust store and verify mode. */ -/* ------------------------------------------------------------------ */ - +/** + * @brief lsquic SSL-context callback: hand lsquic the client SSL_CTX. + * + * Returns the client-configured SSL_CTX so the QUIC handshake honors the + * client's trust store and verify mode. + * + * @param peer_ctx The cwist_http3_client registered as peer context. + * @param local Unused. + * @return The client's SSL_CTX, or NULL if @p peer_ctx is NULL. + */ static SSL_CTX *h3c_get_ssl_ctx(void *peer_ctx, const struct sockaddr *local) { (void)local; cwist_http3_client *client = peer_ctx; diff --git a/src/net/http/http_client.c b/src/net/http/http_client.c index 9905dcf32..1d79673c3 100644 --- a/src/net/http/http_client.c +++ b/src/net/http/http_client.c @@ -33,6 +33,20 @@ typedef struct { int header_count; } response_headers_t; +/** + * @brief libcurl write callback that accumulates the response body into a growable buffer. + * + * Grows the buffer geometrically with realloc. If the accumulated body would + * exceed CWIST_HTTP_MAX_BODY_SIZE or memory allocation fails, the transfer is + * aborted by returning a short count (0). + * + * @param contents pointer to the chunk of response data delivered by libcurl. + * @param size size of each element (per libcurl contract). + * @param nmemb number of elements (per libcurl contract). + * @param userp pointer to response_body_t accumulator. + * @return number of bytes consumed; a value different from size*nmemb aborts + * the transfer. + */ static size_t write_callback(void *contents, size_t size, size_t nmemb, void *userp) { size_t total = size * nmemb; response_body_t *body = (response_body_t *)userp; @@ -53,6 +67,22 @@ static size_t write_callback(void *contents, size_t size, size_t nmemb, void *us return total; } +/** + * @brief libcurl header callback that collects response status and headers. + * + * Parses the HTTP status line to record the status code, and appends each + * "Name: value" header as a new node (prepended to the list). A new status + * line (redirect hop or 1xx interim reply) discards headers collected so + * far, so only the final response's headers are kept. Header collection is + * capped at 200 entries per response to limit resource use; allocation + * failures are tolerated by skipping that header. + * + * @param ptr pointer to the header line delivered by libcurl. + * @param size size of each element (per libcurl contract). + * @param nmemb number of elements (per libcurl contract). + * @param userp pointer to response_headers_t accumulator. + * @return number of bytes consumed; 0 aborts the transfer (header cap hit). + */ static size_t header_callback(char *ptr, size_t size, size_t nmemb, void *userp) { size_t total = size * nmemb; response_headers_t *rh = (response_headers_t *)userp; @@ -142,6 +172,16 @@ struct cwist_http_client { char *altsvc_db; }; +/** + * @brief Create a new HTTP client handle with default settings. + * + * Acquires the shared curl global state, then initializes an easy handle + * with defaults: redirect following (max 10 hops), 30 s timeout, TCP + * keepalive, automatic content decoding, and a CWIST user agent. + * + * @return new client handle, or NULL on allocation/curl init failure (the + * curl global reference is released in that case). + */ cwist_http_client *cwist_http_client_create(void) { cwist_curl_global_acquire(); @@ -174,6 +214,15 @@ cwist_http_client *cwist_http_client_create(void) { return client; } +/** + * @brief Destroy a client handle and release all associated resources. + * + * Cleans up the curl easy handle, frees the CA bundle and alt-svc paths, + * frees the handle, and releases the shared curl global reference. Safe to + * call with NULL. + * + * @param client client handle to destroy, or NULL. + */ void cwist_http_client_destroy(cwist_http_client *client) { if (!client) return; if (client->curl) { @@ -185,18 +234,39 @@ void cwist_http_client_destroy(cwist_http_client *client) { cwist_curl_global_release(); } +/** + * @brief Enable or disable automatic redirect following. + * + * @param client client handle. + * @param follow non-zero to follow redirects, 0 to disable. + */ void cwist_http_client_set_follow_redirects(cwist_http_client *client, int follow) { if (!client) return; client->follow_redirects = follow; curl_easy_setopt(client->curl, CURLOPT_FOLLOWLOCATION, follow ? 1L : 0L); } +/** + * @brief Set the total request timeout in milliseconds. + * + * @param client client handle. + * @param timeout_ms timeout in milliseconds (applied to the whole transfer). + */ void cwist_http_client_set_timeout_ms(cwist_http_client *client, int timeout_ms) { if (!client) return; client->timeout_ms = timeout_ms; curl_easy_setopt(client->curl, CURLOPT_TIMEOUT_MS, (long)timeout_ms); } +/** + * @brief Set the path to a custom CA certificate bundle for TLS verification. + * + * Replaces any previously set path. Pass NULL to fall back to libcurl's + * default trust store. + * + * @param client client handle. + * @param path filesystem path to the CA bundle, or NULL. + */ void cwist_http_client_set_ca_bundle(cwist_http_client *client, const char *path) { if (!client) return; cwist_free(client->ca_bundle); @@ -204,6 +274,16 @@ void cwist_http_client_set_ca_bundle(cwist_http_client *client, const char *path curl_easy_setopt(client->curl, CURLOPT_CAINFO, client->ca_bundle); } +/** + * @brief Enable or disable HTTP alternative services (H1/H2/H3 upgrade). + * + * When enabled, libcurl may advertise and switch to alternative services + * for subsequent requests; requires a database path set via + * cwist_http_client_set_altsvc_db. + * + * @param client client handle. + * @param enabled non-zero to enable alt-svc, 0 to disable. + */ void cwist_http_client_enable_altsvc(cwist_http_client *client, int enabled) { if (!client) return; client->altsvc_enabled = enabled; @@ -211,6 +291,14 @@ void cwist_http_client_enable_altsvc(cwist_http_client *client, int enabled) { enabled ? (long)(CURLALTSVC_H1 | CURLALTSVC_H2 | CURLALTSVC_H3) : 0L); } +/** + * @brief Set the filesystem path of the alt-svc cache database. + * + * Replaces any previously set path. Pass NULL to clear it. + * + * @param client client handle. + * @param path path to the alt-svc cache file, or NULL. + */ void cwist_http_client_set_altsvc_db(cwist_http_client *client, const char *path) { if (!client) return; cwist_free(client->altsvc_db); @@ -222,6 +310,29 @@ void cwist_http_client_set_altsvc_db(cwist_http_client *client, const char *path /* Request execution */ /* ------------------------------------------------------------------ */ +/** + * @brief Execute a single HTTP request. + * + * The handle is reset and re-configured from the client settings before + * each call, so a client may be reused for multiple requests. The response + * body is capped at CWIST_HTTP_MAX_BODY_SIZE and headers at 200 entries; + * exceeding either cap fails the transfer. HTTP/2 is preferred with + * downgrade to HTTP/1.1. On success a cwist_http_response is allocated and + * stored in *out_response (caller must free it with + * cwist_http_response_free); *out_response is always set to NULL before + * the transfer starts. The client is not thread-safe; concurrent requests + * require separate handles. + * + * @param client client handle. + * @param url request URL. + * @param method HTTP method. + * @param headers linked list of request headers, or NULL. + * @param body request body, or NULL for no body. + * @param body_len length of body in bytes. + * @param out_response output for the allocated response object. + * @return cwist_error_t with err_i16 set to 0 on success, -1 on failure + * (invalid arguments, curl transfer error, or allocation failure). + */ cwist_error_t cwist_http_client_request(cwist_http_client *client, const char *url, cwist_http_method_t method, cwist_http_header_node *headers, const char *body, size_t body_len, diff --git a/src/net/http/https.c b/src/net/http/https.c index 7ed68fe13..4ac71ceef 100644 --- a/src/net/http/https.c +++ b/src/net/http/https.c @@ -39,7 +39,10 @@ bool cwist_tls_apply_pqc_layer(cwist_app *app, SSL_CTX *ctx); * response headers; larger bodies amortize the per-record overhead. */ #define CWIST_TLS_COALESCE_MAX (16 * 1024) -/* Monotonic clock in milliseconds, for connection deadlines. */ +/** + * @brief Monotonic clock in milliseconds, for connection deadlines. + * @return Milliseconds since an unspecified epoch; safe only for deltas. + */ static uint64_t cwist_https_now_ms(void) { struct timespec ts; clock_gettime(CLOCK_MONOTONIC, &ts); @@ -119,6 +122,14 @@ static void https_connection_teardown(cwist_https_connection *conn); static __thread bool t_https_parked = false; static void https_close_conn_cb(void *handle); +/** + * @brief Worker loop for the HTTPS thread pool: blocks for queued tasks and + * runs each through https_thread_handler(). + * @param arg Unused (worker index not needed). + * @return Always NULL for pthread compatibility. + * @note If the per-task payload allocation fails, the task's connection (or + * raw fd/SSL pair) is closed so no resource leaks. + */ static void *https_pool_worker(void *arg) { (void)arg; while (1) { @@ -157,6 +168,12 @@ static void *https_pool_worker(void *arg) { return NULL; } +/** + * @brief Create the worker threads, mutex, and condition variables of the + * global HTTPS thread pool. No-op if already initialized. + * @return 0 on success (or if already initialized), -1 if a worker thread + * could not be created. + */ int https_pool_init(void) { if (g_https_pool_initialized) return 0; memset(&g_https_pool, 0, sizeof(g_https_pool)); @@ -172,12 +189,30 @@ int https_pool_init(void) { return 0; } +/** + * @brief Queue a freshly accepted client socket whose TLS handshake still + * needs to run in a pool worker. + * @param client_fd Accepted TCP socket descriptor. + * @param ctx HTTPS context for the handshake. + * @param handler Callback invoked once the connection is established. + * @param user_ctx Opaque pointer forwarded to the handler. + */ void https_pool_submit(int client_fd, cwist_https_context *ctx, void (*handler)(cwist_https_connection *, void *), void *user_ctx) { https_pool_submit_ready(client_fd, NULL, ctx, handler, user_ctx); } -/* Submit a connection whose TLS handshake already completed (pres_ssl). */ +/** + * @brief Submit a connection whose TLS handshake already completed (pres_ssl). + * Blocks while the queue is full; on pool shutdown closes and frees + * the socket instead of queueing. + * @param client_fd Accepted TCP socket descriptor. + * @param pres_ssl Established SSL session, or NULL if the worker must run + * the handshake itself. + * @param ctx HTTPS context. + * @param handler Callback invoked for the connection. + * @param user_ctx Opaque pointer forwarded to the handler. + */ void https_pool_submit_ready(int client_fd, SSL *pres_ssl, cwist_https_context *ctx, void (*handler)(cwist_https_connection *, void *), void *user_ctx) { pthread_mutex_lock(&g_https_pool.mutex); @@ -202,8 +237,15 @@ void https_pool_submit_ready(int client_fd, SSL *pres_ssl, cwist_https_context * pthread_mutex_unlock(&g_https_pool.mutex); } -/* Submit an established connection back to the worker pool (e.g. keep-alive re-arm after async - * defer). */ +/** + * @brief Submit an established connection back to the worker pool (e.g. + * keep-alive re-arm after async defer). Blocks while the queue is + * full; on pool shutdown closes the connection instead of queueing. + * @param conn Established connection wrapper (ownership transfers to the pool). + * @param ctx HTTPS context. + * @param handler Callback invoked for the connection. + * @param user_ctx Opaque pointer forwarded to the handler. + */ void https_pool_submit_conn(cwist_https_connection *conn, cwist_https_context *ctx, void (*handler)(cwist_https_connection *, void *), void *user_ctx) { if (!conn) return; @@ -228,6 +270,11 @@ void https_pool_submit_conn(cwist_https_connection *conn, cwist_https_context *c pthread_mutex_unlock(&g_https_pool.mutex); } +/** + * @brief Shut down the global HTTPS thread pool: signal workers to exit, + * join them, close any connections still queued, and destroy the + * pool's mutex and condition variables. No-op if never initialized. + */ void https_pool_destroy(void) { if (!g_https_pool_initialized) return; https_hs_shepherd_stop(); @@ -297,6 +344,11 @@ static struct { https_parked_t *head[2], *tail[2]; } g_park = {.lock = PTHREAD_MUTEX_INITIALIZER, .epoll_fd = -1}; +/** + * @brief Detach a parked entry from its class list. + * @param p Entry to unlink; must be linked. + * @note Called with g_park.lock held. + */ static void park_unlink_locked(https_parked_t *p) { if (p->prev) p->prev->next = p->next; else g_park.head[p->cls] = p->next; @@ -306,11 +358,24 @@ static void park_unlink_locked(https_parked_t *p) { p->linked = false; } +/** + * @brief Hand a parked connection back to the worker pool. + * @param conn Connection to resubmit. + * @param expired True when the park idle deadline passed; HTTP/2 sessions use + * this to send GOAWAY instead of serving the wakeup read. + */ static void park_resubmit(cwist_https_connection *conn, bool expired) { conn->park_expired = expired; https_pool_submit_conn(conn, conn->pool_ctx, conn->pool_handler, conn->pool_user_ctx); } +/** + * @brief Park-thread main loop: epoll-wait on parked fds, resubmit + * connections that become readable, and expire idle ones past their + * deadline (HTTP/1.1 closed, HTTP/2 resubmitted with park_expired). + * @param arg Unused. + * @return Always NULL for pthread compatibility. + */ static void *https_park_thread(void *arg) { (void)arg; struct epoll_event events[1024]; @@ -361,7 +426,14 @@ static void *https_park_thread(void *arg) { return NULL; } -/* Called with g_park.lock held. */ +/** + * @brief Ensure the park thread and its epoll set are running for this + * process. After a fork the child starts a fresh, empty set because the + * parent's parked connections belong to the parent. + * @return true if the park set is running when the call returns, false on + * epoll/thread creation failure. + * @note Called with g_park.lock held. + */ static bool https_park_start_locked(void) { pid_t pid = getpid(); if (g_park.running && g_park.owner == pid) return true; @@ -383,6 +455,12 @@ static bool https_park_start_locked(void) { return true; } +/** + * @brief Report whether connection parking may be used: enabled unless the + * CWIST_HTTPS_PARK env var disables it, and never under full GC + * (parked allocations are thread-owned and would move threads). + * @return true if parking is allowed. + */ static bool https_park_enabled(void) { static int enabled = -1; if (enabled < 0) { diff --git a/src/net/http/https_upgrade_hook.c b/src/net/http/https_upgrade_hook.c index 561eef040..ecba22052 100644 --- a/src/net/http/https_upgrade_hook.c +++ b/src/net/http/https_upgrade_hook.c @@ -15,6 +15,20 @@ */ #include +/** + * @brief Optional hook called after a successful TLS upgrade of a connection. + * + * Weak default implementation that keeps cwist ownership of the connection. + * Applications that need to take ownership of a TLS-upgraded connection + * (e.g. reverse-session hijacking) provide their own strong definition of + * this symbol, which overrides this stub at static link time. + * + * @param conn The upgraded HTTPS connection. + * @param req The HTTP request that triggered the upgrade. + * @param res The HTTP response to be sent before detaching. + * @return true to detach the fd/ssl from cwist so they are not closed after + * the response is sent; false to keep cwist ownership (the default). + */ __attribute__((weak)) bool cwist_https_upgrade_handler(cwist_https_connection *conn, cwist_http_request *req, cwist_http_response *res) { diff --git a/src/net/http/multipart.c b/src/net/http/multipart.c index fabe032ce..68134107b 100644 --- a/src/net/http/multipart.c +++ b/src/net/http/multipart.c @@ -34,6 +34,18 @@ typedef struct { static void mp_parse_headers(mp_parse_ctx *ctx); +/** + * @brief multipart-parser-c callback: header field fragment received. + * + * Appends the fragment to the buffered header field name (truncated at the + * fixed buffer size). If a header value was already seen for the previous + * field, that pair is finalized via mp_parse_headers() before buffering. + * + * @param p Parser instance (carries mp_parse_ctx). + * @param at Pointer to the fragment. + * @param len Fragment length. + * @return 0 on success. + */ static int mp_on_header_field(multipart_parser *p, const char *at, size_t len) { mp_parse_ctx *ctx = (mp_parse_ctx *)multipart_parser_get_data(p); if (ctx->have_value) { @@ -47,6 +59,17 @@ static int mp_on_header_field(multipart_parser *p, const char *at, size_t len) { return 0; } +/** + * @brief multipart-parser-c callback: header value fragment received. + * + * Marks the current header as having a value and appends the fragment to the + * buffered header value (truncated at the fixed buffer size). + * + * @param p Parser instance (carries mp_parse_ctx). + * @param at Pointer to the fragment. + * @param len Fragment length. + * @return 0 on success. + */ static int mp_on_header_value(multipart_parser *p, const char *at, size_t len) { mp_parse_ctx *ctx = (mp_parse_ctx *)multipart_parser_get_data(p); ctx->have_value = true; @@ -57,6 +80,16 @@ static int mp_on_header_value(multipart_parser *p, const char *at, size_t len) { return 0; } +/** + * @brief Finalize the buffered header field/value pair into ctx state. + * + * NUL-terminates both buffers (truncating at their fixed sizes), then, for + * "Content-Disposition", extracts the name="..." and filename="..." tags, and + * for "Content-Type" stores the whole value. Not thread-safe: operates on the + * caller's ctx. Resets the field/value buffers and have_value afterwards. + * + * @param ctx Parse context to update in place. + */ static void mp_parse_headers(mp_parse_ctx *ctx) { if (ctx->header_field_len >= sizeof(ctx->header_field)) ctx->header_field_len = sizeof(ctx->header_field) - 1; @@ -101,12 +134,32 @@ static void mp_parse_headers(mp_parse_ctx *ctx) { ctx->have_value = false; } +/** + * @brief multipart-parser-c callback: all headers of a part were received. + * + * Flushes any pending header pair via mp_parse_headers() so its values are + * visible before part data begins. + * + * @param p Parser instance (carries mp_parse_ctx). + * @return 0 on success. + */ static int mp_on_headers_complete(multipart_parser *p) { mp_parse_ctx *ctx = (mp_parse_ctx *)multipart_parser_get_data(p); mp_parse_headers(ctx); return 0; } +/** + * @brief multipart-parser-c callback: start of a new part. + * + * Flushes any pending header pair, resets header/name/filename/content_type + * buffers and the data accumulator. Any buffered ctx->data from a previous + * part is freed here; ownership of the current accumulation passes to the + * cwist_multipart_field created in mp_on_part_data_end. + * + * @param p Parser instance (carries mp_parse_ctx). + * @return 0 on success. + */ static int mp_on_part_data_begin(multipart_parser *p) { mp_parse_ctx *ctx = (mp_parse_ctx *)multipart_parser_get_data(p); if (ctx->have_value) { @@ -127,6 +180,17 @@ static int mp_on_part_data_begin(multipart_parser *p) { return 0; } +/** + * @brief multipart-parser-c callback: part body fragment received. + * + * Appends the fragment to ctx->data, growing the buffer with cwist_alloc as + * needed (doubling, minimum 256 bytes). The old buffer is freed on growth. + * + * @param p Parser instance (carries mp_parse_ctx). + * @param at Pointer to the fragment. + * @param len Fragment length. + * @return 0 on success, 1 if memory allocation failed (aborts parsing). + */ static int mp_on_part_data(multipart_parser *p, const char *at, size_t len) { mp_parse_ctx *ctx = (mp_parse_ctx *)multipart_parser_get_data(p); if (len == 0) return 0; @@ -147,6 +211,17 @@ static int mp_on_part_data(multipart_parser *p, const char *at, size_t len) { return 0; } +/** + * @brief multipart-parser-c callback: end of a part. + * + * Allocates a cwist_multipart_field, copies name/filename/content_type into + * freshly allocated strings, and transfers ownership of the accumulated + * ctx->data buffer to the field (no copy). The field is prepended to + * ctx->result->fields and the accumulation state is reset to empty. + * + * @param p Parser instance (carries mp_parse_ctx). + * @return 0 on success, 1 if memory allocation failed (aborts parsing). + */ static int mp_on_part_data_end(multipart_parser *p) { mp_parse_ctx *ctx = (mp_parse_ctx *)multipart_parser_get_data(p); cwist_multipart_field *field = @@ -177,6 +252,20 @@ static int mp_on_part_data_end(multipart_parser *p) { return 0; } +/** + * @brief Parse a multipart/form-data body into a field list. + * + * Runs the multipart-parser-c state machine over @p body with @p boundary + * (the boundary without leading dashes). On malformed or truncated input the + * parser stops early and NULL is returned, with all intermediate state freed. + * + * @param body Raw body bytes; must not be NULL. + * @param body_len Length of @p body in bytes. + * @param boundary MIME boundary string, without the leading "--". + * @return Newly allocated cwist_multipart_result on success (caller owns it, + * release with cwist_multipart_result_destroy), NULL on invalid + * arguments, malformed input, or allocation failure. + */ cwist_multipart_result *cwist_multipart_parse(const char *body, size_t body_len, const char *boundary) { if (!body || body_len == 0 || !boundary) return NULL; @@ -236,6 +325,14 @@ cwist_multipart_result *cwist_multipart_parse(const char *body, size_t body_len, return result; } +/** + * @brief Destroy a parse result and everything it owns. + * + * Frees every field's name, filename, content_type, and data, then the field + * nodes and the result itself. Safe to call with NULL. + * + * @param result Result returned by cwist_multipart_parse, or NULL. + */ void cwist_multipart_result_destroy(cwist_multipart_result *result) { if (!result) return; cwist_multipart_field *curr = result->fields; @@ -251,6 +348,18 @@ void cwist_multipart_result_destroy(cwist_multipart_result *result) { cwist_free(result); } +/** + * @brief Extract the boundary parameter from a Content-Type header value. + * + * Scans semicolon-separated parameters of @p content_type, skipping optional + * whitespace, and accepts both quoted and unquoted boundary values. + * + * @param content_type Content-Type header value, e.g. from the request; may be + * NULL. + * @return Newly allocated NUL-terminated boundary string (caller frees with + * cwist_free), or NULL if no boundary parameter is present or + * allocation failed. + */ char *cwist_multipart_extract_boundary(const char *content_type) { if (!content_type) return NULL; const char *p = content_type; diff --git a/src/net/http/mux.c b/src/net/http/mux.c index d306bd934..023368174 100644 --- a/src/net/http/mux.c +++ b/src/net/http/mux.c @@ -196,6 +196,17 @@ void cwist_mux_handle(cwist_mux_router *router, cwist_http_method_t method, cons } } +/** + * @brief Match a request path against a route template containing `:param` segments. + * @param route_tmpl Route template with `:name` placeholders for variable segments. + * @param req_path Request path to test against the template. + * @param out_params On match, receives an allocated query map with extracted parameters + * (NULL when the template had no parameters). Left untouched on mismatch; + * any partially built map is freed internally. + * @return true when the path matches the template exactly (trailing slash ignored), otherwise false. + * @retval true On match, *out_params owns the extracted parameter map (caller-owned). + * @retval false On mismatch, *out_params is unchanged. + */ static bool match_parametric_route(const char *route_tmpl, const char *req_path, cwist_query_map **out_params) { const char *t = route_tmpl; @@ -242,6 +253,13 @@ static bool match_parametric_route(const char *route_tmpl, const char *req_path, return false; } +/** + * @brief Test whether a request path matches a wildcard route template. + * @param route_tmpl Route template; only templates ending in '*' are treated as wildcards. + * @param req_path Request path to test. + * @return true when the template ends in '*' and req_path starts with the template prefix + * (everything before the '*'), otherwise false. + */ static bool match_wildcard_route(const char *route_tmpl, const char *req_path) { size_t len = strlen(route_tmpl); if (len > 0 && route_tmpl[len - 1] == '*') { @@ -251,6 +269,13 @@ static bool match_wildcard_route(const char *route_tmpl, const char *req_path) { return false; } +/** + * @brief Advance the middleware chain: invoke the next middleware or the terminal handler. + * @param req Request carrying the chain state in req->route_middleware_state. + * @param res Response object forwarded to the current middleware or handler. + * @note The chain state is a stack-allocated struct owned by run_middleware_chain; + * this must only be called while that function is on the stack. + */ static void mux_chain_next(cwist_http_request *req, cwist_http_response *res) { typedef struct { cwist_mux_middleware_node *current; @@ -266,6 +291,16 @@ static void mux_chain_next(cwist_http_request *req, cwist_http_response *res) { } } +/** + * @brief Run a route's middleware chain followed by its handler, in order. + * @param mw Head of the middleware list for the matched route. + * @param req Request to process; req->route_middleware_state is set to point at the + * chain state for the duration of the call and cleared to NULL afterwards. + * @param res Response object forwarded through the chain. + * @param handler Terminal handler invoked once the middleware list is exhausted. + * @note The chain state lives on the stack of this function; middleware callbacks + * must not retain req->route_middleware_state beyond the synchronous call. + */ static void run_middleware_chain(cwist_mux_middleware_node *mw, cwist_http_request *req, cwist_http_response *res, cwist_http_handler_func handler) { typedef struct { @@ -278,6 +313,14 @@ static void run_middleware_chain(cwist_mux_middleware_node *mw, cwist_http_reque req->route_middleware_state = NULL; } +/** + * @brief Look up a registered route for a method/path pair without dispatching to it. + * @param router Router whose exact and parametric routes are searched. + * @param method HTTP method to match. + * @param path Request path to resolve; NULL is treated as the root. + * @return Matching route record, or NULL when no exact or parametric route matches. + * @note Wildcard routes are not considered by this lookup. + */ cwist_mux_route *cwist_mux_find_route(cwist_mux_router *router, cwist_http_method_t method, const char *path) { if (!router || !path) return NULL; @@ -308,6 +351,12 @@ cwist_mux_route *cwist_mux_find_route(cwist_mux_router *router, cwist_http_metho /* --- Route Groups --- */ +/** + * @brief Create a route group that registers all its handlers under a shared path prefix. + * @param router Router that will own the routes registered through the group. + * @param prefix Path prefix prepended to every route path registered on the group. + * @return Newly allocated group, or NULL when an argument is NULL or allocation fails. + */ cwist_mux_group *cwist_mux_group_create(cwist_mux_router *router, const char *prefix) { if (!router || !prefix) return NULL; cwist_mux_group *group = (cwist_mux_group *)cwist_alloc(sizeof(cwist_mux_group)); @@ -322,12 +371,27 @@ cwist_mux_group *cwist_mux_group_create(cwist_mux_router *router, const char *pr return group; } +/** + * @brief Destroy a route group and its stored prefix string. + * @param group Group to destroy. NULL is ignored. + * @note Only the group record is freed; routes registered through the group remain + * owned by the router and are released by cwist_mux_router_destroy(). + */ void cwist_mux_group_destroy(cwist_mux_group *group) { if (!group) return; cwist_free(group->prefix); cwist_free(group); } +/** + * @brief Register a method/path handler on the router, prefixed by the group's prefix. + * @param group Group supplying the path prefix and target router. + * @param method HTTP method to match. + * @param path Route path relative to the group prefix; the two are concatenated verbatim. + * @param handler Callback to invoke for a matching request. + * @note Invalid arguments (NULL group, router, path, or handler) are silently ignored, + * as is an allocation failure while building the full path. + */ void cwist_mux_group_handle(cwist_mux_group *group, cwist_http_method_t method, const char *path, cwist_http_handler_func handler) { if (!group || !group->router || !path || !handler) return; @@ -343,6 +407,13 @@ void cwist_mux_group_handle(cwist_mux_group *group, cwist_http_method_t method, /* --- Per-Route Middleware --- */ +/** + * @brief Attach a middleware callback to a route, applied only when that route is served. + * @param route Route to attach the middleware to. NULL is ignored. + * @param mw Middleware function to prepend to the route's chain. NULL is ignored. + * @note Middleware runs in most-recently-added-first order because each node is + * prepended to the head of the list. An allocation failure is silently ignored. + */ void cwist_mux_route_use(cwist_mux_route *route, cwist_middleware_func mw) { if (!route || !mw) return; cwist_mux_middleware_node *node = diff --git a/src/net/http/query.c b/src/net/http/query.c index 91d19ac2e..c78d241a6 100644 --- a/src/net/http/query.c +++ b/src/net/http/query.c @@ -15,6 +15,12 @@ #define CWIST_QUERY_MAP_DEFAULT_SIZE 16 +/** + * @brief Duplicate a string using the map's allocator. + * @param arena Memory arena to allocate from, or NULL to use heap allocation. + * @param str Source string; NULL yields NULL. + * @return Newly allocated copy owned by the arena or heap, or NULL on failure. + */ static char *query_strdup(void *arena, const char *str) { if (!str) return NULL; size_t len = strlen(str); @@ -169,6 +175,14 @@ const char *cwist_query_map_get(cwist_query_map *map, const char *key) { return NULL; } +/** + * @brief Remove a key/value pair from the map if present. + * @param map Query map to modify; NULL is a no-op. + * @param key Key to delete; NULL is a no-op. + * @retval In heap-managed maps the node and its strings are freed. + * @retval In arena-managed maps the node is only unlinked; storage is reclaimed with the arena. + * (Nothing is freed.) + */ void cwist_query_map_delete(cwist_query_map *map, const char *key) { if (!map || !key) return; if (map->arena) { @@ -215,6 +229,13 @@ void cwist_query_map_delete(cwist_query_map *map, const char *key) { } } +/** + * @brief Invoke a callback for every key/value pair stored in the map. + * @param map Query map to iterate; NULL is a no-op. + * @param cb Callback invoked as cb(key, value, ctx) for each entry; NULL is a no-op. + * @param ctx Opaque pointer forwarded to the callback for each entry. + * @note Iteration order is bucket order and is not sorted. + */ void cwist_query_map_foreach(cwist_query_map *map, cwist_query_map_iter_func cb, void *ctx) { if (!map || !cb) return; for (size_t i = 0; i < map->size; i++) { diff --git a/src/net/http/session.c b/src/net/http/session.c index 960fd7384..2b4da3178 100644 --- a/src/net/http/session.c +++ b/src/net/http/session.c @@ -68,6 +68,13 @@ struct cwist_session { static const char b64[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; +/** + * @brief Encode bytes as RFC 4648 base64 with padding. + * @param data Input bytes (may be NULL when len is 0). + * @param len Number of input bytes. + * @return Newly allocated NUL-terminated string, or NULL on allocation + * failure. Caller owns the returned buffer. + */ static char *base64_encode(const uint8_t *data, size_t len) { size_t out_len = 4 * ((len + 2) / 3); char *out = cwist_alloc(out_len + 1); @@ -85,6 +92,15 @@ static char *base64_encode(const uint8_t *data, size_t len) { return out; } +/** + * @brief Decode an RFC 4648 base64 string into raw bytes. + * @param in NUL-terminated base64 input. + * @param out Output buffer of at least out_len bytes. + * @param out_len Capacity of out. + * @return Number of bytes written on success. + * @retval -1 if the input length is not a multiple of 4, contains invalid + * characters, or the decoded length exceeds out_len. + */ static int base64_decode(const char *in, uint8_t *out, size_t out_len) { size_t in_len = strlen(in); if (in_len % 4 != 0) return -1; @@ -117,6 +133,15 @@ static int base64_decode(const char *in, uint8_t *out, size_t out_len) { /* --- HMAC-SHA256 -------------------------------------------------------- */ +/** + * @brief Compute HMAC-SHA256 of a message. + * @param key HMAC key bytes. + * @param key_len Key length in bytes. + * @param msg Message bytes. + * @param msg_len Message length in bytes. + * @param out Output buffer of exactly 32 bytes. + * @return true on success, false if the MAC could not be computed. + */ static bool hmac_sha256(const char *key, size_t key_len, const char *msg, size_t msg_len, uint8_t out[32]) { #if defined(__EMSCRIPTEN__) || defined(__wasi__) @@ -131,6 +156,15 @@ static bool hmac_sha256(const char *key, size_t key_len, const char *msg, size_t /* --- Secret management -------------------------------------------------- */ +/** + * @brief Generate a random hex-encoded secret of len random bytes. + * Draws entropy from /dev/urandom, falling back to getentropy() on WASI or + * the host CSPRNG on Emscripten. + * @param len Number of random bytes to generate. + * @return Newly allocated NUL-terminated hex string of 2*len characters, + * or NULL if entropy could not be gathered. Caller owns the returned + * buffer. + */ static char *generate_secret(size_t len) { char *secret = cwist_alloc(len * 2 + 1); if (!secret) return NULL; @@ -175,6 +209,16 @@ static char *generate_secret(size_t len) { return NULL; } +/** + * @brief Enable signed session cookies for an app. + * Installs or replaces the app's session secret. If secret is NULL or + * empty, a random secret is generated. Also applies the default cookie + * name and max age when they have not been set. + * @param app Application to configure; must not be NULL. + * @param secret HMAC secret, or NULL/empty to generate one. + * @return 0 on success. + * @retval -1 if app is NULL or the secret could not be set. + */ int cwist_app_use_session(cwist_app *app, const char *secret) { if (!app) return -1; if (app->session_secret) cwist_free(app->session_secret); @@ -189,12 +233,24 @@ int cwist_app_use_session(cwist_app *app, const char *secret) { return 0; } +/** + * @brief Set the session cookie name for an app. + * Replaces any previously set name. No-op if app or name is NULL. + * @param app Application to configure. + * @param name Cookie name; copied internally. + */ void cwist_app_set_session_name(cwist_app *app, const char *name) { if (!app || !name) return; if (app->session_name) cwist_free(app->session_name); app->session_name = cwist_strdup(name); } +/** + * @brief Set the session cookie lifetime in seconds. + * No-op if app is NULL or seconds is not positive. + * @param app Application to configure. + * @param seconds Cookie max age in seconds; must be > 0. + */ void cwist_app_set_session_max_age(cwist_app *app, int seconds) { if (!app || seconds <= 0) return; app->session_max_age = seconds; @@ -206,6 +262,13 @@ typedef struct { cJSON *json; } json_ctx_t; +/** + * @brief query_map_foreach callback that adds one string key/value pair to + * the cJSON object held in a json_ctx_t. NULL values are stored as "". + * @param key Map key. + * @param value Map value, or NULL. + * @param ctx Pointer to a json_ctx_t. + */ static void add_to_json(const char *key, const char *value, void *ctx) { json_ctx_t *jctx = ctx; cJSON_AddStringToObject(jctx->json, key, value ? value : ""); @@ -213,6 +276,15 @@ static void add_to_json(const char *key, const char *value, void *ctx) { /* --- Session load/save -------------------------------------------------- */ +/** + * @brief Verify the HMAC-SHA256 signature of a base64-encoded cookie + * payload against the app secret. + * @param app App whose session_secret is the HMAC key. + * @param payload_b64 Base64-encoded payload to authenticate. + * @param sig_b64 Base64-encoded 32-byte HMAC signature. + * @return true if the signature matches (compared in constant time), + * false otherwise. + */ static bool verify_signature(cwist_app *app, const char *payload_b64, const char *sig_b64) { uint8_t sig[32]; if (base64_decode(sig_b64, sig, sizeof(sig)) != 32) return false; @@ -231,6 +303,13 @@ static bool verify_signature(cwist_app *app, const char *payload_b64, const char #endif } +/** + * @brief Parse a base64-encoded session cookie payload into a query map. + * Payloads larger than 8192 bytes (base64) are rejected. + * @param payload_b64 Base64-encoded JSON object of string key/value pairs. + * @return New query map on success (possibly empty), NULL if decoding, + * JSON parsing, or allocation fails. Caller owns the returned map. + */ static cwist_query_map *parse_payload(const char *payload_b64) { uint8_t payload[8192]; int len = base64_decode(payload_b64, payload, sizeof(payload) - 1); @@ -253,6 +332,17 @@ static cwist_query_map *parse_payload(const char *payload_b64) { return map; } +/** + * @brief Create a session for a request, loading data from the signed + * session cookie if present and valid. If the app has no secret yet, one + * is generated. The session is stored in req->session. + * @param app Application holding session configuration. + * @param req Request to attach the session to. + * @param res Response used for cookie parsing context. + * @return New empty or loaded session on success, NULL on invalid + * arguments or allocation failure. Caller must free with + * cwist_session_destroy(). + */ cwist_session_t *cwist_session_start(cwist_app *app, cwist_http_request *req, cwist_http_response *res) { if (!app || !req || !res) return NULL; @@ -293,11 +383,27 @@ cwist_session_t *cwist_session_start(cwist_app *app, cwist_http_request *req, return session; } +/** + * @brief Get a session value by key. + * @param session Session returned by cwist_session_start(). + * @param key Key to look up. + * @return Value string, or NULL if session/key is NULL or the key is not + * set. The returned pointer is owned by the session. + */ const char *cwist_session_get(cwist_session_t *session, const char *key) { if (!session || !session->data || !key) return NULL; return cwist_query_map_get(session->data, key); } +/** + * @brief Set a session value; NULL value stores an empty string. Marks the + * session as modified so it is written back on commit. + * @param session Session to update. + * @param key Key to set; must not be NULL. + * @param value Value string, or NULL to store "". + * @return 0 on success. + * @retval -1 if session, its data, or key is NULL. + */ int cwist_session_set(cwist_session_t *session, const char *key, const char *value) { if (!session || !key) return -1; if (!session->data) return -1; @@ -306,24 +412,50 @@ int cwist_session_set(cwist_session_t *session, const char *key, const char *val return 0; } +/** + * @brief Delete a key from the session. Marks the session as modified so + * the change is written back on commit. No-op if session, its data, or key + * is NULL. + * @param session Session to update. + * @param key Key to remove. + */ void cwist_session_delete(cwist_session_t *session, const char *key) { if (!session || !session->data || !key) return; cwist_query_map_delete(session->data, key); session->modified = true; } +/** + * @brief Invalidate the session: the session cookie is expired on the next + * commit. Marks the session as modified. + * @param session Session to invalidate. + */ void cwist_session_invalidate(cwist_session_t *session) { if (!session) return; session->invalidated = true; session->modified = true; } +/** + * @brief Free a session and its data. Safe to call with NULL. + * @param session Session to destroy; not usable afterwards. + */ void cwist_session_destroy(cwist_session_t *session) { if (!session) return; cwist_query_map_destroy(session->data); cwist_free(session); } +/** + * @brief Write the session back to the response as a signed cookie. + * If the session was invalidated, the cookie is deleted (expired) instead. + * A no-op when the session is unmodified and already loaded from the + * request, so read-only requests avoid re-setting the cookie. + * @param session Session to persist. + * @param res Response to attach the Set-Cookie header to. + * @return 0 on success (including the no-op and invalidation cases). + * @retval -1 if session or res is NULL, or serialization/signing fails. + */ int cwist_session_commit(cwist_session_t *session, cwist_http_response *res) { if (!session || !res) return -1; if (!session->modified && session->loaded) return 0; @@ -374,6 +506,14 @@ int cwist_session_commit(cwist_session_t *session, cwist_http_response *res) { /* Middleware ------------------------------------------------------------- */ +/** + * @brief Middleware handler: start a session for the request, run the next + * handler, then commit the session back to the response. Skips session + * handling when the request has no app. + * @param req Request being served. + * @param res Response being built. + * @param next Next handler in the chain; always invoked. + */ static void cwist_mw_session_handler(cwist_http_request *req, cwist_http_response *res, cwist_handler_func next) { cwist_app *app = req->app; @@ -388,6 +528,13 @@ static void cwist_mw_session_handler(cwist_http_request *req, cwist_http_respons } } +/** + * @brief Create the session middleware for an app. + * Ensures the app has a session secret, generating a random one if needed, + * so the middleware works without a prior cwist_app_use_session() call. + * @param app Application the middleware serves. + * @return Middleware function that starts and commits sessions per request. + */ cwist_middleware_func cwist_mw_session(cwist_app *app) { if (app && !app->session_secret) { cwist_app_use_session(app, NULL); diff --git a/src/net/http/sse.c b/src/net/http/sse.c index 01dc60b60..517d677c3 100644 --- a/src/net/http/sse.c +++ b/src/net/http/sse.c @@ -14,23 +14,50 @@ struct cwist_sse_stream { pthread_mutex_t mutex; }; +/** @brief Wrap a plain integer result as a cwist error value. + * @param value 0 on success, -1 on failure. + * @return Error value on the CWIST_ERR_INT16 channel. + */ static cwist_error_t sse_error(int value) { return (cwist_error_t){.errtype = CWIST_ERR_INT16, .error.err_i16 = value}; } -/* sstring calls report on the INT8 channel and a failed allocation on the +/** @brief Check an sstring error result and dispose of it. + * + * sstring calls report on the INT8 channel and a failed allocation on the * JSON channel, so neither shows up in err_i16; check with - * cwist_error_is_ok() and release the error payload. */ + * cwist_error_is_ok() and release the error payload. + * + * @param err Error value to check; ownership is always consumed. + * @return true on success, false on failure. + */ static bool sse_ok(cwist_error_t err) { bool ok = cwist_error_is_ok(&err); cwist_error_dispose(&err); return ok; } +/** @brief Append raw bytes to an sstring, translating its error result. + * @param out Destination string. + * @param data Bytes to append. + * @param len Number of bytes in @p data. + * @return true on success, false on allocation or append failure. + */ static bool sse_append(cwist_sstring *out, const char *data, size_t len) { return sse_ok(cwist_sstring_append_len(out, data, len)); } +/** @brief Append one SSE field line to a frame, splitting multi-line values. + * + * Writes "name:value\n" for each line of @p value, splitting on '\n' and + * stripping a trailing '\r' from each line. A NULL value is written as an + * empty field. + * + * @param out Destination string. + * @param name Field name; an empty name produces a comment line (":value"). + * @param value Field value, may be NULL or contain newlines. + * @return 0 on success, -1 if appending fails (out may be partially written). + */ static int append_field(cwist_sstring *out, const char *name, const char *value) { const char *line = value ? value : ""; do { @@ -45,6 +72,20 @@ static int append_field(cwist_sstring *out, const char *name, const char *value) return 0; } +/** @brief Build a complete SSE frame as an sstring. + * + * Formats either a comment line (when @p is_comment is set) or the full + * event block with id/event/retry/data fields followed by the mandatory + * blank line. + * + * @param event Event type field, optional. + * @param id Event id field, optional. + * @param retry_ms Reconnection delay in milliseconds, negative to omit. + * @param data Field payload; for comments this is the comment text. + * @param is_comment Non-zero to format a comment instead of an event. + * @return Newly allocated frame on success, NULL on allocation or + * formatting failure (the frame is destroyed in that case). + */ static cwist_sstring *format_event(const char *event, const char *id, int retry_ms, const char *data, int is_comment) { cwist_sstring *frame = cwist_sstring_create(); @@ -65,6 +106,14 @@ static cwist_sstring *format_event(const char *event, const char *id, int retry_ return frame; } +/** @brief Prepare a response buffer for a Server-Sent Events reply. + * + * Adds the Content-Type, Cache-Control, and X-Accel-Buffering headers + * required for SSE and enables keep-alive. + * + * @param res Response to initialize; must not be NULL. + * @return 0 on success, -1 if @p res is NULL or a header could not be added. + */ cwist_error_t cwist_sse_response_init(cwist_http_response *res) { if (!res || !sse_ok(cwist_http_header_add(&res->headers, "Content-Type", @@ -76,6 +125,16 @@ cwist_error_t cwist_sse_response_init(cwist_http_response *res) { return sse_error(0); } +/** @brief Append a formatted SSE event to a response buffer. + * + * @param res Response whose body receives the frame; must not be NULL. + * @param event Event type field, optional. + * @param id Event id field, optional. + * @param retry_ms Reconnection delay in milliseconds; -1 to omit. + * @param data Event payload data. + * @return 0 on success, -1 if @p res is NULL, @p retry_ms is less than -1, + * or the frame could not be built or appended. + */ cwist_error_t cwist_sse_response_event(cwist_http_response *res, const char *event, const char *id, int retry_ms, const char *data) { if (!res || retry_ms < -1) return sse_error(-1); @@ -86,6 +145,12 @@ cwist_error_t cwist_sse_response_event(cwist_http_response *res, const char *eve return result; } +/** @brief Append an SSE comment line to a response buffer. + * @param res Response whose body receives the comment; must not be NULL. + * @param comment Comment text. + * @return 0 on success, -1 if @p res is NULL or the comment frame could not + * be built or appended. + */ cwist_error_t cwist_sse_response_comment(cwist_http_response *res, const char *comment) { if (!res) return sse_error(-1); cwist_sstring *frame = format_event(NULL, NULL, -1, comment, 1); @@ -95,11 +160,26 @@ cwist_error_t cwist_sse_response_comment(cwist_http_response *res, const char *c return result; } +/** @brief Append an SSE event described by a cwist_sse_event_t to a response. + * @param res Response whose body receives the frame. + * @param event Event descriptor; must not be NULL. + * @return 0 on success, -1 on failure (see cwist_sse_response_event()). + */ cwist_error_t cwist_sse_response_write(cwist_http_response *res, const cwist_sse_event_t *event) { if (!event) return sse_error(-1); return cwist_sse_response_event(res, event->event, event->id, event->retry_ms, event->data); } +/** @brief Send all bytes on a socket, retrying on EINTR. + * + * Suppresses SIGPIPE via MSG_NOSIGNAL. Not thread-safe by itself; callers + * must serialize access to @p fd. + * + * @param fd Socket file descriptor. + * @param data Bytes to send. + * @param len Number of bytes in @p data. + * @return 0 when all bytes were sent, -1 on send error or closed socket. + */ static int send_all(int fd, const char *data, size_t len) { while (len) { ssize_t n = send(fd, data, len, MSG_NOSIGNAL); @@ -111,6 +191,17 @@ static int send_all(int fd, const char *data, size_t len) { return 0; } +/** @brief Upgrade a request connection to a live SSE stream. + * + * Sends the SSE HTTP response headers and marks the request as upgraded, + * transferring ownership of the connection to the returned stream (release + * with cwist_sse_stream_close()). + * + * @param req Request to upgrade; must not be NULL and must not already be + * upgraded. + * @return New stream on success, NULL if the request is invalid or the + * headers could not be sent or the stream could not be allocated. + */ cwist_sse_stream_t *cwist_sse_stream_open(cwist_http_request *req) { static const char headers[] = "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream; charset=utf-8\r\nCache-Control: no-cache\r\nX-Accel-Buffering: no\r\nConnection: keep-alive\r\n\r\n"; @@ -126,6 +217,19 @@ cwist_sse_stream_t *cwist_sse_stream_open(cwist_http_request *req) { return stream; } +/** @brief Send a formatted SSE event on a live stream. + * + * Serializes access to the stream with its mutex; a failed send marks the + * stream as closed. + * + * @param stream Stream to write to; must not be NULL. + * @param event Event type field, optional. + * @param id Event id field, optional. + * @param retry_ms Reconnection delay in milliseconds; -1 to omit. + * @param data Event payload data. + * @return 0 on success, -1 if the stream or frame is invalid, the stream is + * closed, or the send fails. + */ cwist_error_t cwist_sse_stream_send(cwist_sse_stream_t *stream, const char *event, const char *id, int retry_ms, const char *data) { if (!stream || retry_ms < -1) return sse_error(-1); @@ -139,6 +243,16 @@ cwist_error_t cwist_sse_stream_send(cwist_sse_stream_t *stream, const char *even return sse_error(rc); } +/** @brief Send an SSE comment line on a live stream. + * + * Serializes access to the stream with its mutex; a failed send marks the + * stream as closed. + * + * @param stream Stream to write to; must not be NULL. + * @param comment Comment text. + * @return 0 on success, -1 if the stream or frame is invalid, the stream is + * closed, or the send fails. + */ cwist_error_t cwist_sse_stream_comment(cwist_sse_stream_t *stream, const char *comment) { if (!stream) return sse_error(-1); cwist_sstring *frame = format_event(NULL, NULL, -1, comment, 1); @@ -151,11 +265,23 @@ cwist_error_t cwist_sse_stream_comment(cwist_sse_stream_t *stream, const char *c return sse_error(rc); } +/** @brief Send an SSE event described by a cwist_sse_event_t on a stream. + * @param stream Stream to write to. + * @param event Event descriptor; must not be NULL. + * @return 0 on success, -1 on failure (see cwist_sse_stream_send()). + */ cwist_error_t cwist_sse_stream_write(cwist_sse_stream_t *stream, const cwist_sse_event_t *event) { if (!event) return sse_error(-1); return cwist_sse_stream_send(stream, event->event, event->id, event->retry_ms, event->data); } +/** @brief Close a live SSE stream and release its resources. + * + * Destroys the stream mutex and frees the stream; the underlying socket + * remains owned by the upgraded request. + * + * @param stream Stream to close; NULL is silently ignored. + */ void cwist_sse_stream_close(cwist_sse_stream_t *stream) { if (!stream) return; pthread_mutex_destroy(&stream->mutex); diff --git a/src/net/http/tls_chain.c b/src/net/http/tls_chain.c index 94bde5421..2bcc856cc 100644 --- a/src/net/http/tls_chain.c +++ b/src/net/http/tls_chain.c @@ -32,10 +32,31 @@ typedef struct cwist_cert_fetch_buffer { size_t cap; } cwist_cert_fetch_buffer; +/** + * @brief Check whether a URL uses the http or https scheme. + * + * Comparison is case-insensitive and matches only the scheme prefix. + * + * @param url URL string to inspect; may be NULL. + * @return true if @p url starts with "http://" or "https://", false otherwise. + */ static bool cwist_url_has_http_scheme(const char *url) { return url && (strncasecmp(url, "http://", 7) == 0 || strncasecmp(url, "https://", 8) == 0); } +/** + * @brief libcurl write callback that appends response bytes to a fetch buffer. + * + * Grows the buffer geometrically up to CWIST_AIA_MAX_RESPONSE_BYTES. Any + * failure (size overflow, size cap exceeded, or realloc failure) aborts the + * transfer by returning a short count. + * + * @param ptr incoming data bytes. + * @param size size of each element. + * @param nmemb number of elements. + * @param userdata pointer to cwist_cert_fetch_buffer. + * @return number of bytes consumed (size * nmemb) on success, 0 on failure. + */ static size_t cwist_cert_fetch_write_cb(void *ptr, size_t size, size_t nmemb, void *userdata) { if (size != 0 && nmemb > SIZE_MAX / size) return 0; @@ -67,6 +88,18 @@ static size_t cwist_cert_fetch_write_cb(void *ptr, size_t size, size_t nmemb, vo return total; } +/** + * @brief Fetch a certificate (or certificate container) over HTTP(S). + * + * Only http/https URLs are accepted. The curl global state is acquired for + * the duration of the request. Timeouts and redirect limits are bounded by + * CWIST_AIA_* constants. On success @p out owns a malloc'd buffer that the + * caller must free; on failure @p out is zeroed and holds nothing. + * + * @param url http or https URL to fetch. + * @param out fetch buffer to fill; must not be NULL. + * @return true on success, false on failure. + */ static bool cwist_fetch_cert_url(const char *url, cwist_cert_fetch_buffer *out) { if (!cwist_url_has_http_scheme(url) || !out) return false; @@ -110,6 +143,15 @@ static bool cwist_fetch_cert_url(const char *url, cwist_cert_fetch_buffer *out) return true; } +/** + * @brief Parse a single DER-encoded X.509 certificate. + * + * OpenSSL error state is cleared before returning NULL on parse failure. + * + * @param data DER certificate bytes. + * @param len length of @p data. + * @return newly allocated stack containing one X509 (caller frees), or NULL. + */ static STACK_OF(X509) * cwist_parse_der_x509(const uint8_t *data, size_t len) { if (!data || len == 0 || len > LONG_MAX) return NULL; @@ -129,6 +171,17 @@ static STACK_OF(X509) * cwist_parse_der_x509(const uint8_t *data, size_t len) { return certs; } +/** + * @brief Parse PEM-encoded X.509 certificates. + * + * Reads every PEM certificate from the buffer. OpenSSL error state is + * cleared before returning. + * + * @param data PEM certificate text. + * @param len length of @p data. + * @return newly allocated stack of X509 (caller frees), or NULL if none + * could be parsed. + */ static STACK_OF(X509) * cwist_parse_pem_x509(const uint8_t *data, size_t len) { if (!data || len == 0 || len > INT_MAX) return NULL; @@ -160,6 +213,15 @@ static STACK_OF(X509) * cwist_parse_pem_x509(const uint8_t *data, size_t len) { return certs; } +/** + * @brief Parse a DER-encoded PKCS#7 container and extract its certificates. + * + * OpenSSL error state is cleared before returning NULL on failure. + * + * @param data DER PKCS#7 bytes. + * @param len length of @p data. + * @return newly allocated stack of X509 (caller frees), or NULL. + */ static STACK_OF(X509) * cwist_parse_der_pkcs7(const uint8_t *data, size_t len) { if (!data || len == 0) return NULL; @@ -177,6 +239,15 @@ static STACK_OF(X509) * cwist_parse_der_pkcs7(const uint8_t *data, size_t len) { return certs; } +/** + * @brief Parse a PEM-encoded PKCS#7 container and extract its certificates. + * + * OpenSSL error state is cleared before returning NULL on failure. + * + * @param data PEM PKCS#7 text. + * @param len length of @p data. + * @return newly allocated stack of X509 (caller frees), or NULL. + */ static STACK_OF(X509) * cwist_parse_pem_pkcs7(const uint8_t *data, size_t len) { if (!data || len == 0 || len > INT_MAX) return NULL; @@ -200,6 +271,16 @@ static STACK_OF(X509) * cwist_parse_pem_pkcs7(const uint8_t *data, size_t len) { return certs; } +/** + * @brief Try each supported certificate container format in turn. + * + * Attempts, in order: DER X.509, PEM X.509, DER PKCS#7, PEM PKCS#7. + * + * @param data raw certificate bytes of unknown encoding. + * @param len length of @p data. + * @return newly allocated stack of X509 from the first format that parses + * (caller frees), or NULL if none matched. + */ static STACK_OF(X509) * cwist_parse_certificates(const uint8_t *data, size_t len) { STACK_OF(X509) *certs = cwist_parse_der_x509(data, len); if (certs) return certs; @@ -213,6 +294,17 @@ static STACK_OF(X509) * cwist_parse_certificates(const uint8_t *data, size_t len return cwist_parse_pem_pkcs7(data, len); } +/** + * @brief Check whether @p issuer is a valid issuing CA for @p subject. + * + * Rejects identical certificates, non-CA issuers, name/constraint + * mismatches, and signatures that do not verify with the issuer's public + * key. No reference counts are changed. + * + * @param issuer candidate issuer certificate. + * @param subject candidate subject (child) certificate. + * @return true if @p issuer validly signed @p subject, false otherwise. + */ static bool cwist_x509_is_valid_issuer(X509 *issuer, X509 *subject) { if (!issuer || !subject || X509_cmp(issuer, subject) == 0) return false; if (X509_check_ca(issuer) != 1) return false; @@ -222,6 +314,17 @@ static bool cwist_x509_is_valid_issuer(X509 *issuer, X509 *subject) { return issuer_key && X509_verify(subject, issuer_key) == 1; } +/** + * @brief Find the first certificate in @p candidates that validly issued + * @p subject. + * + * The returned pointer is borrowed from @p candidates; no reference count + * is incremented. + * + * @param subject certificate whose issuer is sought. + * @param candidates stack of candidate issuer certificates. + * @return matching issuer certificate, or NULL if none qualifies. + */ static X509 *cwist_find_issuer(X509 *subject, STACK_OF(X509) * candidates) { if (!subject || !candidates) return NULL; @@ -234,6 +337,17 @@ static X509 *cwist_find_issuer(X509 *subject, STACK_OF(X509) * candidates) { return NULL; } +/** + * @brief Check whether @p cert is already present in the SSL_CTX chain. + * + * Compares against both the leaf certificate and the extra chain + * certificates. Returned pointers are borrowed; no reference counts change. + * + * @param ssl_ctx SSL context to inspect. + * @param cert certificate to look for. + * @return true if an identical certificate is already installed, false + * otherwise. + */ static bool cwist_ctx_contains_cert(SSL_CTX *ssl_ctx, X509 *cert) { if (!ssl_ctx || !cert) return false; @@ -249,6 +363,16 @@ static bool cwist_ctx_contains_cert(SSL_CTX *ssl_ctx, X509 *cert) { return false; } +/** + * @brief Return the last certificate in the SSL_CTX chain. + * + * The returned pointer is borrowed from the SSL_CTX; no reference count + * is incremented. If no extra chain certificates are set, the leaf + * certificate is returned. + * + * @param ssl_ctx SSL context to inspect. + * @return chain-tail certificate, or NULL if @p ssl_ctx has no certificate. + */ static X509 *cwist_ctx_chain_tail(SSL_CTX *ssl_ctx) { if (!ssl_ctx) return NULL; @@ -259,6 +383,14 @@ static X509 *cwist_ctx_chain_tail(SSL_CTX *ssl_ctx) { return SSL_CTX_get0_certificate(ssl_ctx); } +/** + * @brief Check whether a certificate is self-signed (self-issued and its + * signature verifies with its own public key). + * + * @param cert certificate to test. + * @return true if @p cert is NULL (treated as end of chain) or self-signed, + * false otherwise. + */ static bool cwist_x509_is_self_signed(X509 *cert) { if (!cert) return true; if (X509_NAME_cmp(X509_get_subject_name(cert), X509_get_issuer_name(cert)) != 0) { @@ -269,6 +401,16 @@ static bool cwist_x509_is_self_signed(X509 *cert) { return key && X509_verify(cert, key) == 1; } +/** + * @brief Extract an http/https caIssuers URI from an ACCESS_DESCRIPTION. + * + * Only ad_caIssuers entries whose location is a GEN_URI without embedded + * NUL bytes and with an http/https scheme are accepted. + * + * @param desc access description from a certificate's AIA extension. + * @return newly allocated NUL-terminated URL string (caller frees), or + * NULL if the entry is not a usable http/https URI. + */ static char *cwist_dup_aia_uri(const ACCESS_DESCRIPTION *desc) { if (!desc || OBJ_obj2nid(desc->method) != NID_ad_ca_issuers || !desc->location) { return NULL; @@ -296,6 +438,18 @@ static char *cwist_dup_aia_uri(const ACCESS_DESCRIPTION *desc) { return url; } +/** + * @brief Fetch a certificate container from @p url and return an issuer of + * @p subject found within it. + * + * Fetches the URL, parses it in every supported format, and selects a + * valid issuing certificate. On success the returned X509 owns one new + * reference that the caller must free. + * + * @param url http/https URL to fetch. + * @param subject certificate whose issuer is sought. + * @return newly referenced issuer certificate (caller frees), or NULL. + */ static X509 *cwist_fetch_issuer_from_url(const char *url, X509 *subject) { cwist_cert_fetch_buffer buf = {0}; if (!cwist_fetch_cert_url(url, &buf)) return NULL; @@ -313,6 +467,18 @@ static X509 *cwist_fetch_issuer_from_url(const char *url, X509 *subject) { return issuer; } +/** + * @brief Fetch an issuer certificate via the subject's AIA caIssuers URLs. + * + * Iterates the subject's Authority Information Access extension and tries + * each http/https caIssuers URI in order until a valid issuer is found. + * OpenSSL error state is cleared if no AIA extension is present. On + * success the returned X509 owns one new reference that the caller must + * free. + * + * @param subject certificate whose AIA extension should be consulted. + * @return newly referenced issuer certificate (caller frees), or NULL. + */ static X509 *cwist_fetch_issuer_from_aia(X509 *subject) { if (!subject) return NULL; @@ -338,6 +504,19 @@ static X509 *cwist_fetch_issuer_from_aia(X509 *subject) { return issuer; } +/** + * @brief Autoload missing intermediate CA certificates into an SSL_CTX chain. + * + * Starting from the current chain tail, walks up the issuance path using AIA + * caIssuers URLs until a self-signed (root) certificate is reached, the chain + * depth limit (CWIST_AIA_MAX_CHAIN_DEPTH) is hit, or no issuer can be + * fetched. Issuers that would duplicate a certificate already installed are + * not added. + * + * @param ssl_ctx SSL context whose certificate chain should be completed. + * @return number of intermediate certificates added (0 if none were needed or + * found), or -1 on error. + */ int cwist_tls_autoload_intermediates(SSL_CTX *ssl_ctx) { if (!ssl_ctx) return -1; diff --git a/src/net/http/writer_fast.c b/src/net/http/writer_fast.c index 9ffd64a52..d1cfec593 100644 --- a/src/net/http/writer_fast.c +++ b/src/net/http/writer_fast.c @@ -12,6 +12,22 @@ #include #include +/** + * @brief Attempt a single non-blocking speculative write of a buffer to a socket. + * + * Performs one best-effort send() with MSG_DONTWAIT (plus MSG_NOSIGNAL when + * available). Never blocks: if the socket buffer is full, the write is left + * pending for the caller's blocking drain path. + * + * @param fd Connected socket descriptor. + * @param buf Data to send; must not be NULL unless len is 0. + * @param len Number of bytes to send. + * @param sent_out Optional output; receives the number of bytes accepted by + * the socket (0 on EAGAIN or when nothing was sent). + * @retval CWIST_WRITE_DONE The whole buffer was sent. + * @retval CWIST_WRITE_PENDING Partial progress or the socket would block. + * @retval CWIST_WRITE_ERR A hard send error occurred. + */ cwist_write_status_t cwist_http_send_speculative(int fd, const void *buf, size_t len, size_t *sent_out) { if (!buf || len == 0) { @@ -45,6 +61,25 @@ cwist_write_status_t cwist_http_send_speculative(int fd, const void *buf, size_t return CWIST_WRITE_ERR; } +/** + * @brief Attempt a single non-blocking speculative sendmsg over an iovec array. + * + * Like cwist_http_send_speculative(), but sends a scatter/gather buffer. Under + * CWIST_WASI_SOCKETS (no sendmsg(2) in wasi:sockets 0.2) the iovecs are + * coalesced into one heap allocation (owned and freed here) and sent with a + * single send(); the blocking drain in cwist_http_sendmsg_all() re-drives + * whatever remains. + * + * @param fd Connected socket descriptor. + * @param iov Array of iovec entries; must not be NULL unless iovcnt <= 0. + * @param iovcnt Number of entries in iov. + * @param flags Extra send flags ORed into MSG_DONTWAIT/MSG_NOSIGNAL. + * @param total_sent Optional output; receives total bytes accepted by the + * socket (0 on EAGAIN or when nothing was sent). + * @retval CWIST_WRITE_DONE The entire payload was sent. + * @retval CWIST_WRITE_PENDING Partial progress or the socket would block. + * @retval CWIST_WRITE_ERR Allocation or hard send error occurred. + */ cwist_write_status_t cwist_http_sendmsg_speculative(int fd, struct iovec *iov, int iovcnt, int flags, size_t *total_sent) { if (!iov || iovcnt <= 0) { @@ -110,6 +145,19 @@ cwist_write_status_t cwist_http_sendmsg_speculative(int fd, struct iovec *iov, i return CWIST_WRITE_ERR; } +/** + * @brief Select the less-loaded of two randomly sampled workers (power-of-two choices). + * + * Draws two distinct worker indices using a thread-local xorshift32 PRNG, reads + * their current loads with a relaxed atomic load, and returns the index of the + * worker with the smaller load. Thread-safe: the PRNG seed is _Thread_local and + * the load array is only read. + * + * @param worker_loads Array of per-worker load counters (atomic uint32_t). + * @param num_workers Number of workers in the array. + * @return Index of the selected worker; 0 if worker_loads is NULL or + * num_workers <= 1. + */ uint32_t cwist_sched_p2c_select_worker(const _Atomic uint32_t *worker_loads, uint32_t num_workers) { if (!worker_loads || num_workers <= 1) return 0; @@ -140,6 +188,15 @@ uint32_t cwist_sched_p2c_select_worker(const _Atomic uint32_t *worker_loads, uin return (load1 <= load2) ? w1 : w2; } +/** + * @brief Return a cheap monotonic timestamp in whole seconds. + * + * Uses CLOCK_MONOTONIC_COARSE when available for speed, falling back to + * CLOCK_MONOTONIC. Intended for coarse-grained elapsed-time checks, not + * sub-second precision. + * + * @return Monotonic time in seconds, truncated to uint32_t. + */ uint32_t cwist_fast_monotonic_sec(void) { struct timespec ts; #if defined(CLOCK_MONOTONIC_COARSE) diff --git a/src/net/nats/cwist_nats.c b/src/net/nats/cwist_nats.c index 6aafc39e6..c44cd611a 100644 --- a/src/net/nats/cwist_nats.c +++ b/src/net/nats/cwist_nats.c @@ -17,6 +17,17 @@ struct cwist_nats { void *user_ctx; }; +/** @brief NATS callback shim that forwards a message to the user callback. + * + * Extracts the subject and payload from the NATS message and invokes the + * user-registered callback with the user context stored in the handle. + * Always destroys the message before returning. + * + * @param nc NATS connection (unused). + * @param sub NATS subscription (unused). + * @param msg Message to deliver; ownership is taken and it is always destroyed. + * @param closure cwist_nats_t handle carrying the user callback and context. + */ static void cwist_nats_adapter(natsConnection *nc, natsSubscription *sub, natsMsg *msg, void *closure) { (void)nc; @@ -31,6 +42,17 @@ static void cwist_nats_adapter(natsConnection *nc, natsSubscription *sub, natsMs natsMsg_Destroy(msg); } +/** @brief Connect to a NATS server and create a handle. + * + * Allocates a cwist_nats_t and connects to the given URL, or to the NATS + * default URL when @p url is NULL. + * + * @param[out] nats Receives the new handle on success; must not be NULL. + * @param url Server URL, or NULL for the default server. + * @return cwist_error_t with err_i16 set to 0 on success, CWIST_ERROR_INVALID_PARAM + * if @p nats is NULL, CWIST_ERROR_NOMEM on allocation failure, or the + * natsStatus code on connection failure. + */ cwist_error_t cwist_nats_connect(cwist_nats_t **nats, const char *url) { cwist_error_t err = make_error(CWIST_ERR_INT16); if (!nats) { @@ -54,6 +76,19 @@ cwist_error_t cwist_nats_connect(cwist_nats_t **nats, const char *url) { return err; } +/** @brief Subscribe to a NATS subject and register a message callback. + * + * Replaces any existing subscription on the handle: the previous subscription + * is destroyed before the new one is created. The callback is invoked from + * the NATS delivery thread via cwist_nats_adapter. + * + * @param nats Handle with an active connection; must not be NULL. + * @param subject Subject to subscribe to; must not be NULL. + * @param cb Callback invoked for each incoming message; may be NULL. + * @param ctx Opaque pointer passed back to @p cb. + * @return cwist_error_t with err_i16 set to 0 on success, CWIST_ERROR_INVALID_PARAM + * on NULL arguments, or the natsStatus code on failure. + */ cwist_error_t cwist_nats_subscribe(cwist_nats_t *nats, const char *subject, cwist_nats_msg_cb cb, void *ctx) { cwist_error_t err = make_error(CWIST_ERR_INT16); @@ -77,6 +112,17 @@ cwist_error_t cwist_nats_subscribe(cwist_nats_t *nats, const char *subject, cwis return err; } +/** @brief Publish a NUL-terminated string message to a subject. + * + * A NULL @p data is published as an empty string. Flushes the connection + * after publishing so the message is written out before returning. + * + * @param nats Handle with an active connection; must not be NULL. + * @param subject Subject to publish to; must not be NULL. + * @param data Payload string, or NULL to send an empty payload. + * @return cwist_error_t with err_i16 set to 0 on success, CWIST_ERROR_INVALID_PARAM + * on NULL arguments, or the natsStatus code on publish/flush failure. + */ cwist_error_t cwist_nats_publish_string(cwist_nats_t *nats, const char *subject, const char *data) { cwist_error_t err = make_error(CWIST_ERR_INT16); if (!nats || !nats->conn || !subject) { @@ -91,12 +137,27 @@ cwist_error_t cwist_nats_publish_string(cwist_nats_t *nats, const char *subject, return err; } +/** @brief Pump pending I/O on the connection. + * + * Flushes the connection, pushing buffered outgoing messages and processing + * incoming traffic so delivered callbacks can run. Safe to call with a NULL + * or disconnected handle; such calls are no-ops. + * + * @param nats Handle to flush, or NULL. + */ void cwist_nats_dispatch(cwist_nats_t *nats) { if (nats && nats->conn) { natsConnection_Flush(nats->conn); } } +/** @brief Destroy a handle and release its resources. + * + * Unsubscribes and disconnects if still active, then frees the handle. + * Safe to call with NULL; the handle must not be used afterwards. + * + * @param nats Handle to destroy, or NULL. + */ void cwist_nats_destroy(cwist_nats_t *nats) { if (!nats) return; if (nats->sub) { @@ -108,6 +169,14 @@ void cwist_nats_destroy(cwist_nats_t *nats) { cwist_free(nats); } +/** @brief Access the underlying NATS connection. + * + * The returned pointer is owned by the handle and remains valid until + * cwist_nats_destroy is called. + * + * @param nats Handle to query, or NULL. + * @return The native natsConnection, or NULL if @p nats is NULL. + */ natsConnection *cwist_nats_native(cwist_nats_t *nats) { return nats ? nats->conn : NULL; } diff --git a/src/net/quic_flow_control.c b/src/net/quic_flow_control.c index d0aa67c00..c24d3b597 100644 --- a/src/net/quic_flow_control.c +++ b/src/net/quic_flow_control.c @@ -4,15 +4,35 @@ #define QUIC_FRAME_MAX_DATA 0x10 #define QUIC_FRAME_MAX_STREAM_DATA 0x11 +/** + * @brief Clamp a flow control window to the protocol minimum. + * + * @param window Requested window size in bytes. + * @return window if it is at least QUIC_FLOW_CONTROL_MIN_INITIAL_WINDOW, + * otherwise QUIC_FLOW_CONTROL_MIN_INITIAL_WINDOW. + */ static uint64_t clamp_window(uint64_t window) { return window < QUIC_FLOW_CONTROL_MIN_INITIAL_WINDOW ? QUIC_FLOW_CONTROL_MIN_INITIAL_WINDOW : window; } +/** + * @brief Add two values with saturation to the QUIC varint maximum. + * + * @param value Base value. + * @param increment Value to add. + * @return value + increment, or QUIC_MAX_VARINT if the sum would overflow it. + */ static uint64_t saturating_add(uint64_t value, uint64_t increment) { return value > QUIC_MAX_VARINT - increment ? QUIC_MAX_VARINT : value + increment; } +/** + * @brief Determine the encoded size of a QUIC varint for a value. + * + * @param value Value to encode. + * @return Encoded size in bytes: 1, 2, 4, or 8. + */ static size_t varint_size(uint64_t value) { if (value < (1ULL << 6)) return 1; if (value < (1ULL << 14)) return 2; @@ -20,6 +40,17 @@ static size_t varint_size(uint64_t value) { return 8; } +/** + * @brief Encode a QUIC varint into a buffer in big-endian byte order. + * + * Writes the value right-aligned in @p size bytes and sets the two-bit + * length prefix in the most significant bits of the first byte. + * The caller must ensure @p buf has at least @p size bytes available. + * + * @param buf Output buffer. + * @param value Value to encode (must fit in @p size bytes). + * @param size Encoded size: 1, 2, 4, or 8. + */ static void write_varint(uint8_t *buf, uint64_t value, size_t size) { size_t i; @@ -28,10 +59,34 @@ static void write_varint(uint8_t *buf, uint64_t value, size_t size) { buf[0] |= (uint8_t)((size == 1 ? 0 : size == 2 ? 1 : size == 4 ? 2 : 3) << 6); } +/** + * @brief Check whether a MAX_DATA-style limit update should be sent. + * + * An update is due when the consumed amount has reached the current + * maximum, or when at least half of the window has been consumed. + * + * @param consumed Total bytes consumed so far. + * @param maximum Current advertised maximum. + * @param window Current flow control window size. + * @return Non-zero if an update should be sent, zero otherwise. + */ static int should_update(uint64_t consumed, uint64_t maximum, uint64_t window) { return consumed >= maximum || consumed + window / 2 >= maximum; } +/** + * @brief Initialize the connection-level flow control state. + * + * Initial and maximum windows are clamped to + * QUIC_FLOW_CONTROL_MIN_INITIAL_WINDOW, and each maximum is raised to at + * least the corresponding initial value. No-op if @p fc is NULL. + * + * @param fc Flow control state to initialize. + * @param initial_conn_window Initial connection-level window in bytes. + * @param initial_stream_window Initial per-stream window in bytes. + * @param max_conn_window Upper bound for the connection window in bytes. + * @param max_stream_window Upper bound for the per-stream window in bytes. + */ void quic_flow_control_init(quic_conn_fc_t *fc, uint64_t initial_conn_window, uint64_t initial_stream_window, uint64_t max_conn_window, uint64_t max_stream_window) { @@ -56,6 +111,16 @@ void quic_flow_control_init(quic_conn_fc_t *fc, uint64_t initial_conn_window, }; } +/** + * @brief Initialize the flow control state for a single stream. + * + * The initial window is clamped to QUIC_FLOW_CONTROL_MIN_INITIAL_WINDOW. + * No-op if @p sfc is NULL. + * + * @param sfc Stream flow control state to initialize. + * @param stream_id ID of the stream this state belongs to. + * @param initial_stream_window Initial per-stream window in bytes. + */ void quic_stream_fc_init(quic_stream_fc_t *sfc, uint64_t stream_id, uint64_t initial_stream_window) { if (!sfc) return; @@ -68,6 +133,17 @@ void quic_stream_fc_init(quic_stream_fc_t *sfc, uint64_t stream_id, }; } +/** + * @brief Feed an RTT sample into the smoothed RTT estimator and re-evaluate quality. + * + * Uses RFC 6298-style smoothing: rttvar is updated with weight 1/4 and srtt + * with weight 1/8; the first sample initializes both. Tracked state is + * confined to @p fc and is not thread-safe across concurrent calls on the + * same connection. No-op if @p fc is NULL or @p rtt_sample_us is zero. + * + * @param fc Connection flow control state. + * @param rtt_sample_us Latest RTT sample in microseconds. + */ void quic_flow_control_update_rtt(quic_conn_fc_t *fc, uint64_t rtt_sample_us) { uint64_t difference; @@ -89,6 +165,17 @@ void quic_flow_control_update_rtt(quic_conn_fc_t *fc, uint64_t rtt_sample_us) { quic_flow_control_evaluate_quality(fc); } +/** + * @brief Recompute the network quality class from the smoothed RTT. + * + * Classifies by srtt thresholds: <= 50 ms excellent, <= 150 ms normal, + * <= 500 ms high RTT, above that poor. The result is stored in + * fc->quality and returned. Returns QUIC_NET_QUALITY_NORMAL if @p fc is + * NULL or no RTT sample has been recorded yet. + * + * @param fc Connection flow control state. + * @return The (updated) quality classification. + */ quic_net_quality_t quic_flow_control_evaluate_quality(quic_conn_fc_t *fc) { if (!fc || !fc->rtt_initialized) return QUIC_NET_QUALITY_NORMAL; @@ -104,6 +191,18 @@ quic_net_quality_t quic_flow_control_evaluate_quality(quic_conn_fc_t *fc) { return fc->quality; } +/** + * @brief Grow flow control windows when the network quality is degraded. + * + * Only acts when the connection quality is at least QUIC_NET_QUALITY_HIGH_RTT. + * Each window that is below its configured maximum is doubled, capped at + * that maximum. No-op if @p fc is NULL or quality is better than + * QUIC_NET_QUALITY_HIGH_RTT; @p sfc may be NULL, in which case only the + * connection window is adjusted. + * + * @param fc Connection flow control state. + * @param sfc Stream flow control state to adjust, or NULL. + */ void quic_flow_control_adjust_windows(quic_conn_fc_t *fc, quic_stream_fc_t *sfc) { if (!fc || fc->quality < QUIC_NET_QUALITY_HIGH_RTT) return; @@ -119,15 +218,53 @@ void quic_flow_control_adjust_windows(quic_conn_fc_t *fc, quic_stream_fc_t *sfc) } } +/** + * @brief Account for connection-level flow control bytes consumed by the peer. + * + * The consumed counter saturates at the QUIC varint maximum instead of + * wrapping. No-op if @p fc is NULL. + * + * @param fc Connection flow control state. + * @param bytes Number of bytes consumed. + */ void quic_flow_control_consume_conn(quic_conn_fc_t *fc, uint64_t bytes) { if (fc) fc->conn_consumed = saturating_add(fc->conn_consumed, bytes); } +/** + * @brief Account for stream-level flow control bytes consumed by the peer. + * + * The consumed counter saturates at the QUIC varint maximum instead of + * wrapping. The connection state @p fc is not touched; @p sfc may be NULL, + * in which case the call is a no-op. + * + * @param fc Connection flow control state (currently unused). + * @param sfc Stream flow control state. + * @param bytes Number of bytes consumed. + */ void quic_flow_control_consume_stream(quic_conn_fc_t *fc, quic_stream_fc_t *sfc, uint64_t bytes) { (void)fc; if (sfc) sfc->stream_consumed = saturating_add(sfc->stream_consumed, bytes); } +/** + * @brief Write a MAX_DATA frame if the connection limit should be raised. + * + * Unless @p force is set, a frame is produced only when at least half of + * the connection window has been consumed (see should_update()). The new + * maximum is consumed + window, never lower than the current maximum, and + * is stored back in fc->max_data on success. On entry *written is cleared + * to 0 when @p written is non-NULL. + * + * @param fc Connection flow control state. + * @param buf Output buffer for the encoded frame. + * @param buf_len Capacity of @p buf in bytes. + * @param written Receives the encoded frame length in bytes. + * @param force Emit the frame even if the update threshold is not met. + * @retval 1 Frame written; *written holds its length. + * @retval 0 No update needed; nothing written. + * @retval -1 Invalid argument or @p buf too small; nothing written. + */ int quic_flow_control_maybe_send_max_data(quic_conn_fc_t *fc, uint8_t *buf, size_t buf_len, size_t *written, bool force) { uint64_t maximum; @@ -149,6 +286,25 @@ int quic_flow_control_maybe_send_max_data(quic_conn_fc_t *fc, uint8_t *buf, size return 1; } +/** + * @brief Write a MAX_STREAM_DATA frame if the stream limit should be raised. + * + * Unless @p force is set, a frame is produced only when at least half of + * the stream window has been consumed (see should_update()). The new + * maximum is consumed + window, never lower than the current maximum, and + * is stored back in sfc->max_stream_data on success. On entry *written is + * cleared to 0 when @p written is non-NULL. + * + * @param fc Connection flow control state. + * @param sfc Stream flow control state. + * @param buf Output buffer for the encoded frame. + * @param buf_len Capacity of @p buf in bytes. + * @param written Receives the encoded frame length in bytes. + * @param force Emit the frame even if the update threshold is not met. + * @retval 1 Frame written; *written holds its length. + * @retval 0 No update needed; nothing written. + * @retval -1 Invalid argument or @p buf too small; nothing written. + */ int quic_flow_control_maybe_send_max_stream_data(quic_conn_fc_t *fc, quic_stream_fc_t *sfc, uint8_t *buf, size_t buf_len, size_t *written, bool force) { diff --git a/src/net/redis/cwist_redis.c b/src/net/redis/cwist_redis.c index 9577db523..c31606250 100644 --- a/src/net/redis/cwist_redis.c +++ b/src/net/redis/cwist_redis.c @@ -50,6 +50,10 @@ struct cwist_redis_pool { /* --- Socket helpers ----------------------------------------------------- */ +/** Open a blocking TCP connection to the Redis server. + * @param host Server host name or IPv4 address. + * @param port Server port. + * @return Connected socket fd on success, -1 on resolution/connection failure. */ static int redis_open_socket(const char *host, int port) { struct addrinfo hints = {0}, *res = NULL; hints.ai_family = AF_INET; @@ -73,6 +77,8 @@ static int redis_open_socket(const char *host, int port) { } // codeql[cpp/cleartext-transmission] +/** Send a buffer fully over the socket, retrying on EINTR. + * @return 0 if all bytes were sent, -1 on send error or disconnect. */ static int redis_send_all(int fd, const char *buf, size_t len) { size_t sent = 0; while (sent < len) { @@ -91,6 +97,12 @@ static int redis_send_all(int fd, const char *buf, size_t len) { return 0; } +/** Read one CRLF-terminated RESP line from the connection's buffered stream, + * stripping the trailing "\r\n". + * @param line Output buffer of @p line_len bytes. + * @return 0 on success, -1 on I/O error, disconnect, or line longer than + * @p line_len - 1 bytes. Not thread-safe by itself; callers must hold + * the connection mutex. */ static int redis_recv_line(cwist_redis_t *r, char *line, size_t line_len) { size_t i = 0; while (1) { @@ -120,6 +132,10 @@ static int redis_recv_line(cwist_redis_t *r, char *line, size_t line_len) { } } +/** Read exactly @p len raw bytes from the connection's buffered stream. + * @return 0 on success, -1 on I/O error or disconnect before @p len bytes + * were read. Not thread-safe by itself; callers must hold the + * connection mutex. */ static int redis_recv_bytes(cwist_redis_t *r, char *out, size_t len) { size_t got = 0; while (got < len) { @@ -147,6 +163,9 @@ static int redis_recv_bytes(cwist_redis_t *r, char *out, size_t len) { void cwist_redis_reply_free(cwist_redis_reply_t *reply); +/** Append one RESP2 bulk string ("$\r\n\r\n") to a frame being + * built. A NULL @p s is encoded as an empty string. + * @return Always 0 (appends to the sstring unconditionally). */ static int write_bulk_string(cwist_sstring *out, const char *s) { char prefix[32]; size_t len = s ? strlen(s) : 0; @@ -293,6 +312,8 @@ static cwist_error_t read_reply(cwist_redis_t *r, char **out_value, size_t *out_ return (cwist_error_t){.errtype = CWIST_ERR_INT16, .error.err_i16 = 0}; } +/** Recursively free a reply tree, including all child elements and string + * payloads. Safe to call with NULL. */ void cwist_redis_reply_free(cwist_redis_reply_t *reply) { if (!reply) return; if (reply->element) { @@ -305,6 +326,11 @@ void cwist_redis_reply_free(cwist_redis_reply_t *reply) { cwist_free(reply); } +/** Send a pre-built RESP frame and read the raw reply tree, holding the + * connection mutex for the whole request/reply pair so concurrent callers + * cannot consume each other's replies. + * @return Error value; on success *@p out_tree holds the reply tree (which the + * caller must free with cwist_redis_reply_free()). */ static cwist_error_t redis_command_frame_tree(cwist_redis_t *r, const char *frame, size_t frame_len, cwist_redis_reply_t **out_tree) { if (!r || !frame) return make_error(CWIST_ERR_INT16); @@ -320,6 +346,10 @@ static cwist_error_t redis_command_frame_tree(cwist_redis_t *r, const char *fram return err; } +/** Send a pre-built RESP frame and flatten the reply into the legacy + * out_value/out_len contract (arrays and errors yield NULL). + * @return Error value; on success *@p out_value (if requested) is a + * heap-allocated value the caller must free. */ static cwist_error_t redis_command_frame(cwist_redis_t *r, const char *frame, size_t frame_len, char **out_value, size_t *out_len) { cwist_redis_reply_t *tree = NULL; @@ -357,6 +387,15 @@ static bool redis_frame_append(cwist_sstring *frame, const void *data, size_t le return ok; } +/** Build a RESP2 frame from an argv array, send it, and return the raw reply + * tree. Unlike the legacy string API, a Redis '-' error reply is surfaced as + * a failure with *@p reply set to NULL. + * @param r Connection handle. + * @param argc Number of command arguments. + * @param argv Argument pointers. + * @param argv_lens Length of each argument in bytes. + * @param reply Out: reply tree on success (caller frees); always NULL on error. + * @return Error value indicating success or failure. */ cwist_error_t cwist_redis_command_argv_reply(cwist_redis_t *r, size_t argc, const void *const *argv, const size_t *argv_lens, cwist_redis_reply_t **reply) { if (!r || !argc || !argv || !argv_lens || !reply) return make_error(CWIST_ERR_INT16); @@ -392,6 +431,11 @@ cwist_error_t cwist_redis_command_argv_reply(cwist_redis_t *r, size_t argc, cons /* --- Connection lifecycle ----------------------------------------------- */ +/** Open a TCP connection to a Redis server and allocate the client handle. + * @param host Server host name or IPv4 address. + * @param port Server port. + * @return New connection handle on success, NULL on connection or allocation + * failure. */ cwist_redis_t *cwist_redis_connect(const char *host, int port) { if (!host || port <= 0) return NULL; int fd = redis_open_socket(host, port); @@ -410,6 +454,9 @@ cwist_redis_t *cwist_redis_connect(const char *host, int port) { return r; } +/** Close a connection and free the handle. Shuts the socket down first to wake + * a subscriber blocked in recv; this is the documented way to stop + * cwist_redis_subscribe(). Safe to call with NULL. */ void cwist_redis_close(cwist_redis_t *r) { if (!r) return; /* shutdown wakes a subscriber blocked in recv before the descriptor is @@ -426,6 +473,10 @@ void cwist_redis_close(cwist_redis_t *r) { /* --- Command helpers ---------------------------------------------------- */ +/** Read one reply from the connection under the mutex and flatten it into the + * legacy out_value contract. + * @return Error value; on success *@p out_value (if non-NULL input) is a + * heap-allocated value the caller must free. */ static cwist_error_t recv_reply(cwist_redis_t *r, char **out_value) { if (!r) return make_error(CWIST_ERR_INT16); pthread_mutex_lock(&r->mtx); @@ -434,6 +485,9 @@ static cwist_error_t recv_reply(cwist_redis_t *r, char **out_value) { return err; } +/** Send one raw command under the connection mutex without waiting for a + * reply (used for SUBSCRIBE-style flows where the reply is read separately). + * @return Error value indicating success or send failure. */ static cwist_error_t send_command(cwist_redis_t *r, const char *cmd) { pthread_mutex_lock(&r->mtx); cwist_error_t err = redis_send_all(r->fd, cmd, strlen(cmd)) == 0 @@ -443,6 +497,13 @@ static cwist_error_t send_command(cwist_redis_t *r, const char *cmd) { return err; } +/** Send a raw RESP command string and return the flattened first-payload + * reply. + * @param r Connection handle. + * @param cmd Full RESP-encoded command. + * @param out Optional out: heap-allocated reply value (caller frees); freed + * internally when NULL. + * @return Error value indicating success or failure. */ cwist_error_t cwist_redis_command(cwist_redis_t *r, const char *cmd, char **out) { if (!r || !cmd) return make_error(CWIST_ERR_INT16); char *value = NULL; @@ -454,6 +515,15 @@ cwist_error_t cwist_redis_command(cwist_redis_t *r, const char *cmd, char **out) return err; } +/** Build a RESP2 frame from an argv array, send it, and return the flattened + * first-payload reply with its length. + * @param r Connection handle. + * @param argc Number of command arguments. + * @param argv Argument pointers. + * @param argv_lens Length of each argument in bytes. + * @param out Optional out: heap-allocated reply value (caller frees). + * @param out_len Optional out: reply length in bytes. + * @return Error value indicating success or failure. */ cwist_error_t cwist_redis_command_argv(cwist_redis_t *r, size_t argc, const void *const *argv, const size_t *argv_lens, char **out, size_t *out_len) { if (!r || !argc || !argv || !argv_lens) return make_error(CWIST_ERR_INT16); @@ -486,6 +556,12 @@ cwist_error_t cwist_redis_command_argv(cwist_redis_t *r, size_t argc, const void return make_error(CWIST_ERR_INT16); } +/** Authenticate against the server with ACL-style (username, password) or + * legacy (password-only) AUTH. + * @param r Connection handle. + * @param username Optional ACL username; NULL selects password-only AUTH. + * @param password Password (required). + * @return Error value indicating success or failure. */ cwist_error_t cwist_redis_auth(cwist_redis_t *r, const char *username, const char *password) { if (!r || !password) return make_error(CWIST_ERR_INT16); char *reply = NULL; @@ -503,6 +579,10 @@ cwist_error_t cwist_redis_auth(cwist_redis_t *r, const char *username, const cha return err; } +/** Select the logical database for subsequent commands (SELECT). + * @param r Connection handle. + * @param database Database index. + * @return Error value indicating success or failure. */ cwist_error_t cwist_redis_select(cwist_redis_t *r, unsigned int database) { char db[16]; snprintf(db, sizeof(db), "%u", database); @@ -514,6 +594,12 @@ cwist_error_t cwist_redis_select(cwist_redis_t *r, unsigned int database) { return err; } +/** Fetch the value of a key (GET). + * @param r Connection handle. + * @param key Key to read. + * @param out_value Out: heap-allocated value on success, NULL when the key is + * missing (caller frees). + * @return Error value indicating success or failure. */ cwist_error_t cwist_redis_get(cwist_redis_t *r, const char *key, char **out_value) { if (!r || !key || !out_value) return make_error(CWIST_ERR_INT16); cwist_sstring *cmd = cwist_sstring_create(); @@ -526,6 +612,11 @@ cwist_error_t cwist_redis_get(cwist_redis_t *r, const char *key, char **out_valu return err; } +/** Set the value of a key (SET). + * @param r Connection handle. + * @param key Key to write. + * @param value Value to store. + * @return Error value indicating success or failure. */ cwist_error_t cwist_redis_set(cwist_redis_t *r, const char *key, const char *value) { if (!r || !key || !value) return make_error(CWIST_ERR_INT16); cwist_sstring *cmd = cwist_sstring_create(); @@ -541,6 +632,12 @@ cwist_error_t cwist_redis_set(cwist_redis_t *r, const char *key, const char *val return err; } +/** Set the value of a key with a time-to-live (SETEX). + * @param r Connection handle. + * @param key Key to write. + * @param value Value to store. + * @param seconds TTL in seconds; must be positive. + * @return Error value indicating success or failure. */ cwist_error_t cwist_redis_setex(cwist_redis_t *r, const char *key, const char *value, int seconds) { if (!r || !key || !value || seconds <= 0) return make_error(CWIST_ERR_INT16); cwist_sstring *cmd = cwist_sstring_create(); @@ -559,6 +656,10 @@ cwist_error_t cwist_redis_setex(cwist_redis_t *r, const char *key, const char *v return err; } +/** Delete a key (DEL). + * @param r Connection handle. + * @param key Key to delete. + * @return Error value indicating success or failure. */ cwist_error_t cwist_redis_del(cwist_redis_t *r, const char *key) { if (!r || !key) return make_error(CWIST_ERR_INT16); cwist_sstring *cmd = cwist_sstring_create(); @@ -573,6 +674,11 @@ cwist_error_t cwist_redis_del(cwist_redis_t *r, const char *key) { return err; } +/** Set a key's time-to-live in seconds (EXPIRE). + * @param r Connection handle. + * @param key Key to expire. + * @param seconds TTL in seconds; must be positive. + * @return Error value indicating success or failure. */ cwist_error_t cwist_redis_expire(cwist_redis_t *r, const char *key, int seconds) { if (!r || !key || seconds <= 0) return make_error(CWIST_ERR_INT16); cwist_sstring *cmd = cwist_sstring_create(); @@ -590,6 +696,11 @@ cwist_error_t cwist_redis_expire(cwist_redis_t *r, const char *key, int seconds) return err; } +/** Publish a message to a channel (PUBLISH). + * @param r Connection handle. + * @param channel Channel to publish to. + * @param message Message payload. + * @return Error value indicating success or failure. */ cwist_error_t cwist_redis_publish(cwist_redis_t *r, const char *channel, const char *message) { if (!r || !channel || !message) return make_error(CWIST_ERR_INT16); cwist_sstring *cmd = cwist_sstring_create(); @@ -607,6 +718,14 @@ cwist_error_t cwist_redis_publish(cwist_redis_t *r, const char *channel, const c /* --- Pub/Sub ------------------------------------------------------------ */ +/** Subscribe to channels and run a blocking message loop, invoking the + * callback for each incoming message. + * @param r Connection handle. + * @param channels NULL-terminated list of channel names. + * @param cb Callback invoked as cb(channel, message, ctx) per message. + * @param ctx Opaque pointer passed through to the callback. + * @return Zero error value; the loop ends when the connection is shut down + * (see cwist_redis_close()). */ cwist_error_t cwist_redis_subscribe(cwist_redis_t *r, const char **channels, cwist_redis_msg_cb cb, void *ctx) { if (!r || !channels || !channels[0] || !cb) return make_error(CWIST_ERR_INT16); @@ -672,6 +791,12 @@ cwist_error_t cwist_redis_subscribe(cwist_redis_t *r, const char **channels, cwi /* --- Connection pool ---------------------------------------------------- */ +/** Create a connection pool with max_conns pre-opened connections. + * @param host Server host name or IPv4 address. + * @param port Server port. + * @param max_conns Number of connections to open; must be non-zero. + * @return New pool handle on success, NULL on any connection or allocation + * failure (partial state is cleaned up). */ cwist_redis_pool_t *cwist_redis_pool_create(const char *host, int port, size_t max_conns) { if (!host || port <= 0 || max_conns == 0) return NULL; @@ -707,6 +832,7 @@ cwist_redis_pool_t *cwist_redis_pool_create(const char *host, int port, size_t m return NULL; } +/** Destroy a pool, closing every connection it owns. Safe to call with NULL. */ void cwist_redis_pool_destroy(cwist_redis_pool_t *pool) { if (!pool) return; pthread_mutex_lock(&pool->mtx); @@ -722,6 +848,9 @@ void cwist_redis_pool_destroy(cwist_redis_pool_t *pool) { cwist_free(pool); } +/** Acquire an idle pool connection, blocking on the condition variable until + * one becomes available. + * @return A connection marked in-use; NULL only when @p pool is NULL. */ static cwist_redis_t *redis_pool_acquire(cwist_redis_pool_t *pool) { if (!pool) return NULL; pthread_mutex_lock(&pool->mtx); @@ -737,6 +866,7 @@ static cwist_redis_t *redis_pool_acquire(cwist_redis_pool_t *pool) { } } +/** Return a connection to the pool and signal a waiting acquirer. */ static void redis_pool_release(cwist_redis_pool_t *pool, cwist_redis_t *r) { if (!pool || !r) return; pthread_mutex_lock(&pool->mtx); @@ -750,6 +880,8 @@ static void redis_pool_release(cwist_redis_pool_t *pool, cwist_redis_t *r) { pthread_mutex_unlock(&pool->mtx); } +/** Pool wrapper of cwist_redis_get(): acquires a connection, runs GET, and + * releases the connection. See cwist_redis_get() for parameter details. */ cwist_error_t cwist_redis_pool_get(cwist_redis_pool_t *pool, const char *key, char **out_value) { cwist_redis_t *r = redis_pool_acquire(pool); if (!r) return make_error(CWIST_ERR_INT16); @@ -758,6 +890,8 @@ cwist_error_t cwist_redis_pool_get(cwist_redis_pool_t *pool, const char *key, ch return err; } +/** Pool wrapper of cwist_redis_set(): acquires a connection, runs SET, and + * releases the connection. See cwist_redis_set() for parameter details. */ cwist_error_t cwist_redis_pool_set(cwist_redis_pool_t *pool, const char *key, const char *value) { cwist_redis_t *r = redis_pool_acquire(pool); if (!r) return make_error(CWIST_ERR_INT16); @@ -766,6 +900,8 @@ cwist_error_t cwist_redis_pool_set(cwist_redis_pool_t *pool, const char *key, co return err; } +/** Pool wrapper of cwist_redis_setex(): acquires a connection, runs SETEX, and + * releases the connection. See cwist_redis_setex() for parameter details. */ cwist_error_t cwist_redis_pool_setex(cwist_redis_pool_t *pool, const char *key, const char *value, int seconds) { cwist_redis_t *r = redis_pool_acquire(pool); @@ -775,6 +911,8 @@ cwist_error_t cwist_redis_pool_setex(cwist_redis_pool_t *pool, const char *key, return err; } +/** Pool wrapper of cwist_redis_del(): acquires a connection, runs DEL, and + * releases the connection. See cwist_redis_del() for parameter details. */ cwist_error_t cwist_redis_pool_del(cwist_redis_pool_t *pool, const char *key) { cwist_redis_t *r = redis_pool_acquire(pool); if (!r) return make_error(CWIST_ERR_INT16); @@ -783,6 +921,9 @@ cwist_error_t cwist_redis_pool_del(cwist_redis_pool_t *pool, const char *key) { return err; } +/** Pool wrapper of cwist_redis_publish(): acquires a connection, runs + * PUBLISH, and releases the connection. See cwist_redis_publish() for + * parameter details. */ cwist_error_t cwist_redis_pool_publish(cwist_redis_pool_t *pool, const char *channel, const char *message) { cwist_redis_t *r = redis_pool_acquire(pool); @@ -792,6 +933,9 @@ cwist_error_t cwist_redis_pool_publish(cwist_redis_pool_t *pool, const char *cha return err; } +/** Pool wrapper of cwist_redis_command_argv(): acquires a connection, runs the + * argv command, and releases the connection. See cwist_redis_command_argv() + * for parameter details. */ cwist_error_t cwist_redis_pool_command_argv(cwist_redis_pool_t *pool, size_t argc, const void *const *argv, const size_t *argv_lens, char **out, size_t *out_len) { diff --git a/src/net/websocket/websocket.c b/src/net/websocket/websocket.c index 13dd6bb0e..b7d3b07fe 100644 --- a/src/net/websocket/websocket.c +++ b/src/net/websocket/websocket.c @@ -163,8 +163,21 @@ static ssize_t read_exact(int fd, void *buf, size_t len) { return total; } -/* Append len bytes of src to ws->frag_buf, growing it as needed. - * Returns false on allocation failure. */ +/** + * @brief Append @p len bytes of @p src to the connection's fragmented-message + * reassembly buffer, growing it as needed. + * + * Doubles the buffer capacity on growth and enforces the + * CWIST_WS_MAX_MESSAGE_BYTES total-message cap even when each individual + * frame passed the per-frame CWIST_WS_MAX_PAYLOAD_BYTES check. A zero-length + * append is a no-op and always succeeds. + * + * @param ws WebSocket connection owning the reassembly buffer. + * @param src Source bytes to copy (may be NULL when @p len is zero). + * @param len Number of bytes to append. + * @return true on success, false on allocation failure or when the message + * would exceed CWIST_WS_MAX_MESSAGE_BYTES. + */ static bool ws_frag_append(cwist_websocket *ws, const uint8_t *src, size_t len) { if (len == 0) return true; /* Reject a reassembled message that exceeds the total-message cap, even diff --git a/src/net/websocket/websocket_async.c b/src/net/websocket/websocket_async.c index ff098fcd0..0cddd9fe7 100644 --- a/src/net/websocket/websocket_async.c +++ b/src/net/websocket/websocket_async.c @@ -112,11 +112,16 @@ static void ws_async_sweep_watchdog_free(ws_async_sweep_t *sweep); static void ws_async_sweep_post_cb(void *ctx); static void *ws_async_sweep_watchdog(void *arg); -/* Mark a sweep context dead exactly once. Lock order is always - * g_ws_sweeps_lock -> sweep->lock, so a foreign-thread attach cannot push a - * new connection onto a sweep whose destruction was already decided. The - * context itself is released by the watchdog thread once it has exited and - * no posted sweep can still reference it. */ +/** + * @brief Mark a sweep context dead exactly once. + * + * Lock order is always g_ws_sweeps_lock -> sweep->lock, so a foreign-thread + * attach cannot push a new connection onto a sweep whose destruction was + * already decided. The context itself is released by the watchdog thread + * once it has exited and no posted sweep can still reference it. + * + * @param sweep Sweep context to mark dead. Must not be NULL. + */ static void ws_async_sweep_mark_dead(ws_async_sweep_t *sweep) { pthread_mutex_lock(&g_ws_sweeps_lock); pthread_mutex_lock(&sweep->lock); @@ -129,10 +134,17 @@ static void ws_async_sweep_mark_dead(ws_async_sweep_t *sweep) { /* Lifecycle */ /* ------------------------------------------------------------------ */ -/* Final teardown: close the fd and release all state. Only called once no - * reactor slot can still dispatch into this connection (the current slot has - * been consumed, and any other pending slot was either canceled or is the - * callback doing the teardown). Runs on the reactor thread only. */ +/** + * @brief Final teardown: close the fd and release all state. + * + * Only called once no reactor slot can still dispatch into this connection + * (the current slot has been consumed, and any other pending slot was either + * canceled or is the callback doing the teardown). Runs on the reactor + * thread only. + * + * @param ws Connection to destroy; ownership of the struct and all buffers + * is released. Must not be NULL. + */ static void ws_async_teardown(cwist_websocket_async *ws) { /* Protocol layers (e.g. graphql-ws) release per-connection state here; * runs before any memory is freed and exactly once per connection. */ @@ -147,9 +159,12 @@ static void ws_async_teardown(cwist_websocket_async *ws) { cwist_free(ws); } -/* Tear down the connection. Single owner of destruction; every exit path - * funnels through here, so it is called at most once per connection. Runs - * on the reactor thread only (read/write events, sweep, inline attach). +/** + * @brief Tear down the connection. + * + * Single owner of destruction; every exit path funnels through here, so it is + * called at most once per connection. Runs on the reactor thread only (read/ + * write events, sweep, inline attach). * * Termination is subtle under the one-shot io_uring poll model: a poll * request still pending in the kernel holds a reference to the file, so @@ -159,7 +174,10 @@ static void ws_async_teardown(cwist_websocket_async *ws) { * connection for the peer immediately (FIN), the pending slot is woken (or * canceled), and its callback performs the real teardown. When nothing is * pending — e.g. the read slot that delivered a CLOSE/EOF was just consumed — - * teardown happens right here, matching the HTTP layer's close() behavior. */ + * teardown happens right here, matching the HTTP layer's close() behavior. + * + * @param ws Connection to terminate. Safe to call with NULL (no-op). + */ static void ws_async_terminate(cwist_websocket_async *ws) { if (!ws) return; if (ws->sweep) { @@ -192,6 +210,10 @@ static void ws_async_terminate(cwist_websocket_async *ws) { ws_async_teardown(ws); } +/** + * @brief Compute the absolute parked-write deadline. + * @return Monotonic timestamp (seconds) WS_ASYNC_PARK_DEADLINE_SEC from now. + */ static uint32_t ws_async_park_deadline(void) { return cwist_fast_monotonic_sec() + WS_ASYNC_PARK_DEADLINE_SEC; } @@ -200,10 +222,15 @@ static uint32_t ws_async_park_deadline(void) { /* Idle reaping */ /* ------------------------------------------------------------------ */ -/* CWIST_WS_ASYNC_IDLE_TIMEOUT_SEC: seconds a WS async connection may stay - * event-free before the lazy reaper closes it. Default 300 (5 minutes); - * 0 disables reaping. Cached once per process, following the - * reactor_drain_chunk() env-knob idiom in src/sys/io/reactor.c. */ +/** + * @brief Read CWIST_WS_ASYNC_IDLE_TIMEOUT_SEC: seconds a WS async connection + * may stay event-free before the lazy reaper closes it. + * + * Default 300 (5 minutes); 0 disables reaping. Cached once per process, + * following the reactor_drain_chunk() env-knob idiom in src/sys/io/reactor.c. + * + * @return Idle timeout in seconds (0 means reaping disabled). + */ static uint32_t ws_async_idle_timeout_sec(void) { static _Atomic int cached = -1; int v = atomic_load_explicit(&cached, memory_order_relaxed); @@ -220,18 +247,32 @@ static uint32_t ws_async_idle_timeout_sec(void) { return (uint32_t)v; } -/* Refresh the activity clock: any complete frame received, any send, or any - * PING/PONG traffic counts as activity (mirrors the HTTP conn's - * last_active_sec updates in src/net/http/http.c). */ +/** + * @brief Refresh the connection activity clock. + * + * Any complete frame received, any send, or any PING/PONG traffic counts as + * activity (mirrors the HTTP conn's last_active_sec updates in + * src/net/http/http.c). + * + * @param ws Connection whose last_active_sec timestamp is updated. + */ static void ws_async_touch(cwist_websocket_async *ws) { ws->last_active_sec = cwist_fast_monotonic_sec(); } -/* Lazy per-event idle reaper, modeled on http_async_event_cb (http.c): the - * one-shot slot model gives each fd at most one read + one POLLOUT slot, so - * a totally silent connection receives no events and is only reaped on its +/** + * @brief Lazy per-event idle reaper, modeled on http_async_event_cb (http.c). + * + * The one-shot slot model gives each fd at most one read + one POLLOUT slot, + * so a totally silent connection receives no events and is only reaped on its * first post-timeout event — the same trade-off the HTTP keep-alive reaper - * makes. Returns true when the connection was reaped. */ + * makes. + * + * @param ws Connection to check. + * @retval true The connection exceeded the idle timeout and was terminated. + * @retval false The connection is still within the timeout (or reaping is + * disabled). + */ static bool ws_async_idle_reap(cwist_websocket_async *ws) { uint32_t timeout_sec = ws_async_idle_timeout_sec(); if (timeout_sec == 0) return false; @@ -269,8 +310,15 @@ static bool ws_async_idle_reap(cwist_websocket_async *ws) { * flight while the context is being torn down; cwist_reactor_destroy() * drains pending posts, so the wait always terminates. */ -/* Sweep cadence: half the timeout, clamped to [1, 60]s, so a silent - * connection is reaped within roughly timeout + interval. */ +/** + * @brief Compute the watchdog sweep cadence. + * + * Half the idle timeout, clamped to [1, 60]s, so a silent connection is + * reaped within roughly timeout + interval. + * + * @param timeout_sec Idle timeout in seconds. + * @return Sweep interval in seconds. + */ static uint32_t ws_async_sweep_interval(uint32_t timeout_sec) { uint32_t interval = timeout_sec / 2; if (interval < 1) interval = 1; @@ -278,12 +326,18 @@ static uint32_t ws_async_sweep_interval(uint32_t timeout_sec) { return interval; } -/* Release the context from the watchdog thread after it has exited its - * posting loop: wait (in short slices) for any posted sweep still queued on - * the reactor to drain, then unlink and free. The context is never freed - * from the reactor thread, so the watchdog can never touch freed memory. +/** + * @brief Release the sweep context from the watchdog thread after it has + * exited its posting loop. + * + * Waits (in short slices) for any posted sweep still queued on the reactor + * to drain, then unlinks and frees. The context is never freed from the + * reactor thread, so the watchdog can never touch freed memory. * cwist_reactor_destroy() drains pending posts, so queued callbacks always - * run and the wait terminates. */ + * run and the wait terminates. + * + * @param sweep Sweep context to free. Must not be NULL. + */ static void ws_async_sweep_watchdog_free(ws_async_sweep_t *sweep) { const struct timespec slice = {.tv_sec = 0, .tv_nsec = 100000000L}; for (;;) { @@ -303,7 +357,17 @@ static void ws_async_sweep_watchdog_free(ws_async_sweep_t *sweep) { cwist_free(sweep); } -/* Runs on the reactor thread, delivered by cwist_reactor_post(). */ +/** + * @brief Sweep tick callback; runs on the reactor thread, delivered by + * cwist_reactor_post(). + * + * Reaps at most one timed-out connection per tick, then decrements the posts + * counter; posts-- is the last touch of the context (the watchdog frees it + * only after observing posts == 0). Marks the sweep dead when the list is + * empty. + * + * @param ctx The sweep context (@p sweep pointer). + */ static void ws_async_sweep_post_cb(void *ctx) { ws_async_sweep_t *sweep = (ws_async_sweep_t *)ctx; @@ -339,6 +403,17 @@ static void ws_async_sweep_post_cb(void *ctx) { * low without a condvar; nanosleep is EINTR-tolerant. */ #define WS_ASYNC_SWEEP_SLICE_MS 100u +/** + * @brief Watchdog thread body: sleep the sweep interval, then post a sweep + * to the reactor. + * + * Sleeps the interval in 100 ms slices so dead-flag shutdown latency stays + * low without a condvar; nanosleep is EINTR-tolerant. On exit (dead flag) + * frees the context via ws_async_sweep_watchdog_free(). + * + * @param arg The sweep context (@p sweep pointer). + * @return Always NULL. + */ static void *ws_async_sweep_watchdog(void *arg) { ws_async_sweep_t *sweep = (ws_async_sweep_t *)arg; const struct timespec slice = {.tv_sec = 0, .tv_nsec = 100000000L}; @@ -371,9 +446,16 @@ static void *ws_async_sweep_watchdog(void *arg) { return NULL; } -/* Find or create the sweep context for @p reactor. Returns NULL when idle - * reaping is disabled (timeout 0) or on setup failure; connections then only - * get the per-event reaper. Safe to call from a foreign thread. */ +/** + * @brief Find or create the sweep context for a reactor. + * + * Returns NULL when idle reaping is disabled (timeout 0) or on setup failure; + * connections then only get the per-event reaper. Safe to call from a + * foreign thread. + * + * @param reactor Reactor whose sweep context is looked up or created. + * @return The (possibly new) sweep context, or NULL on failure/disabled. + */ static ws_async_sweep_t *ws_async_sweep_get(cwist_reactor_t *reactor) { uint32_t timeout_sec = ws_async_idle_timeout_sec(); if (timeout_sec == 0) return NULL; @@ -421,8 +503,17 @@ static ws_async_sweep_t *ws_async_sweep_get(cwist_reactor_t *reactor) { /* Parked (POLLOUT-resumable) writes */ /* ------------------------------------------------------------------ */ -/* Serialize one FIN-terminated server frame (server frames are never - * masked) into a fresh owned buffer. Returns NULL on allocation failure. */ +/** + * @brief Serialize one FIN-terminated server frame (server frames are never + * masked) into a fresh owned buffer. + * + * @param opcode Frame opcode. + * @param data Payload bytes; may be NULL when len is 0 (not copied). + * @param len Payload length in bytes. + * @param out_total Receives the total serialized size (header + payload). + * @return Newly allocated buffer owned by the caller, or NULL on allocation + * failure. + */ static uint8_t *ws_async_serialize_frame(cwist_ws_opcode_t opcode, const uint8_t *data, size_t len, size_t *out_total) { uint8_t head[10]; @@ -450,10 +541,21 @@ static uint8_t *ws_async_serialize_frame(cwist_ws_opcode_t opcode, const uint8_t return buf; } -/* Queue raw frame bytes behind any already-parked unsent bytes, then drain - * speculatively; whatever does not fit in the socket buffer is parked on a - * one-shot POLLOUT slot. Returns 0 when the frame is sent or parked, -1 on - * failure (connection is terminated by the caller). */ +/** + * @brief Queue raw frame bytes behind any already-parked unsent bytes, then + * drain speculatively. + * + * Whatever does not fit in the socket buffer is parked and resumed by a + * one-shot POLLOUT slot. Runs on the reactor thread only. + * + * @param ws Connection to send on. + * @param opcode Frame opcode. + * @param data Payload bytes; may be NULL when len is 0. + * @param len Payload length in bytes. + * @retval 0 The frame was fully sent or parked. + * @retval -1 Allocation or send failure, or the POLLOUT slot could not be + * registered (the connection is terminated by the caller). + */ static int ws_async_queue_frame(cwist_websocket_async *ws, cwist_ws_opcode_t opcode, const uint8_t *data, size_t len) { size_t frame_len = 0; @@ -511,6 +613,18 @@ static int ws_async_queue_frame(cwist_websocket_async *ws, cwist_ws_opcode_t opc return 0; } +/** + * @brief POLLOUT callback: resume a parked write. + * + * Handles deferred termination first (shutdown() woke this slot so it can + * perform the real teardown), then drains the parked buffer. Send progress + * resets the parked-write deadline; on timeout or fatal send error the + * connection is terminated. When the buffer drains, a pending termination + * is performed or the read side is re-armed. Runs on the reactor thread. + * + * @param fd The connection's socket fd. + * @param ctx Reactor slot payload (ws_async_slot_t holding the connection). + */ static void ws_async_write_ready(int fd, void *ctx) { ws_async_slot_t *slot = (ws_async_slot_t *)ctx; cwist_websocket_async *ws = slot->ws; @@ -563,12 +677,35 @@ static void ws_async_write_ready(int fd, void *ctx) { } } +/** + * @brief Send one complete WebSocket message frame on an async connection. + * + * May be called from the reactor thread (e.g. inside the on_message + * callback); partial writes are parked and resumed by a POLLOUT slot. + * + * @param ws Async connection. + * @param opcode Frame opcode (text, binary, PING, ...). + * @param data Payload bytes; may be NULL when len is 0. + * @param len Payload length in bytes. + * @retval 0 The frame was sent or parked. + * @retval -1 Invalid arguments, the connection is already closing/closed, or + * queueing failed (the connection is torn down). + */ int cwist_websocket_async_send(cwist_websocket_async *ws, cwist_ws_opcode_t opcode, const uint8_t *data, size_t len) { if (!ws || ws->closed || (!data && len > 0)) return -1; return ws_async_queue_frame(ws, opcode, data, len); } +/** + * @brief Initiate the WebSocket close handshake with an empty CLOSE frame. + * + * Idempotent: calling again on a connection already closing is a no-op. + * The connection is torn down once the CLOSE frame has drained or, on + * queueing failure, via a deferred termination. + * + * @param ws Async connection to close. Safe to call with NULL. + */ void cwist_websocket_async_close(cwist_websocket_async *ws) { if (!ws || ws->closed) return; ws->closed = true; @@ -586,6 +723,18 @@ void cwist_websocket_async_close(cwist_websocket_async *ws) { /* Otherwise the parked-write completion terminates after draining. */ } +/** + * @brief Initiate the WebSocket close handshake with a CLOSE frame carrying + * a status code. + * + * The 2-byte big-endian status code is echoed to the peer. Idempotent: + * calling again on a connection already closing is a no-op. The connection + * is torn down once the CLOSE frame has drained or, on queueing failure, via + * a deferred termination. + * + * @param ws Async connection to close. Safe to call with NULL. + * @param code RFC 6455 close status code (big-endian on the wire). + */ void cwist_websocket_async_close_code(cwist_websocket_async *ws, uint16_t code) { if (!ws || ws->closed) return; ws->closed = true; @@ -605,6 +754,15 @@ void cwist_websocket_async_close_code(cwist_websocket_async *ws, uint16_t code) /* Incremental frame parsing (mirrors cwist_websocket_receive) */ /* ------------------------------------------------------------------ */ +/** + * @brief Append bytes to the connection's read stash, growing by doubling. + * + * @param ws Connection whose stash buffer is grown. + * @param src Bytes to append; may be NULL when len is 0. + * @param len Number of bytes to append. + * @retval true The bytes were appended. + * @retval false Allocation failure; the stash is unchanged. + */ static bool ws_async_stash_append(cwist_websocket_async *ws, const uint8_t *src, size_t len) { if (len == 0) return true; size_t need = ws->stash_len + len; @@ -623,9 +781,17 @@ static bool ws_async_stash_append(cwist_websocket_async *ws, const uint8_t *src, return true; } -/* Append len bytes of src to the reassembly buffer, growing by doubling. - * Returns false on allocation failure or when the reassembled message would - * exceed the total-message cap. */ +/** + * @brief Append bytes to the fragmented-message reassembly buffer, growing + * by doubling. + * + * @param ws Connection whose reassembly buffer is grown. + * @param src Fragment bytes to append; may be NULL when len is 0. + * @param len Number of bytes to append. + * @retval true The bytes were appended. + * @retval false Allocation failure, or the reassembled message would exceed + * WS_ASYNC_MAX_MESSAGE_BYTES. + */ static bool ws_async_frag_append(cwist_websocket_async *ws, const uint8_t *src, size_t len) { if (len == 0) return true; if (ws->frag_len + len > WS_ASYNC_MAX_MESSAGE_BYTES) return false; @@ -645,8 +811,24 @@ static bool ws_async_frag_append(cwist_websocket_async *ws, const uint8_t *src, return true; } -/* Handle one complete, unmasked frame. Takes ownership of @p payload. - * Returns 0 on success, -1 on a protocol violation (connection must fail). */ +/** + * @brief Handle one complete, unmasked frame. + * + * Performs fragmented-message reassembly, answers CLOSE (echoing the status + * code) and PING (PONG with the same payload) per RFC 6455, and delivers + * data frames through the on_message callback, which takes ownership of the + * delivered frame and its payload. + * + * @param ws Connection receiving the frame. + * @param fin FIN flag of the frame. + * @param opcode Frame opcode. + * @param payload Allocated, unmasked payload; ownership is consumed (freed + * or handed to the on_message callback). + * @param payload_len Payload length in bytes. + * @retval 0 The frame was handled. + * @retval -1 Protocol violation or allocation failure (the connection must + * fail). + */ static int ws_async_process_frame(cwist_websocket_async *ws, bool fin, cwist_ws_opcode_t opcode, uint8_t *payload, size_t payload_len) { bool is_control = (opcode >= 0x8); @@ -737,9 +919,17 @@ static int ws_async_process_frame(cwist_websocket_async *ws, bool fin, cwist_ws_ return 0; } -/* Parse as many complete frames as the stash holds. Partial frame bytes - * stay in the stash for the next read. Returns 0 on success, -1 on a - * protocol violation. */ +/** + * @brief Parse as many complete frames as the stash holds. + * + * Partial frame bytes stay in the stash for the next read. Enforces the + * same RFC 6455 rules as cwist_websocket_receive() (masking, RSV bits, + * payload caps, control-frame constraints, fragmentation sequence). + * + * @param ws Connection whose stash is parsed. + * @retval 0 All complete frames were consumed. + * @retval -1 Protocol violation or allocation failure. + */ static int ws_async_feed(cwist_websocket_async *ws) { size_t off = 0; @@ -810,6 +1000,19 @@ static int ws_async_feed(cwist_websocket_async *ws) { /* Reactor integration */ /* ------------------------------------------------------------------ */ +/** + * @brief Read-event callback: drain the socket, feed the incremental parser, + * and re-arm the one-shot read slot. + * + * Handles deferred termination (closing flag) first, then the lazy idle + * reaper, then drains everything the kernel has without blocking. EOF, + * recv errors, protocol violations from the parser, and a CLOSE requested + * from the on_message callback all converge on ws_async_terminate(). Runs + * on the reactor thread. + * + * @param fd The connection's socket fd. + * @param ctx Reactor slot payload (ws_async_slot_t holding the connection). + */ static void ws_async_read_ready(int fd, void *ctx) { ws_async_slot_t *slot = (ws_async_slot_t *)ctx; cwist_websocket_async *ws = slot->ws; @@ -873,6 +1076,27 @@ static void ws_async_read_ready(int fd, void *ctx) { ws->read_armed = true; } +/** + * @brief Attach an upgraded, HTTP-connection-owned fd to the reactor as an + * async WebSocket connection. + * + * Sets the fd non-blocking, feeds any bytes already read past the upgrade + * request through the parser (delivery can happen inline here, including a + * close from the on_message callback), arms the one-shot read slot, and + * joins the reactor's idle sweep. On any failure the fd is closed and all + * state is freed; on_close does not fire because the attach itself failed. + * + * @param fd Upgraded socket fd (owned by the new connection on success). + * @param reactor Reactor to register the fd with. + * @param on_message Callback invoked per complete message; takes ownership + * of the delivered frame. Must not be NULL. + * @param on_close Optional callback invoked exactly once during teardown. + * @param user_data Opaque pointer passed back to both callbacks. + * @param initial Bytes already read past the upgrade request; may be NULL. + * @param initial_len Number of bytes in initial. + * @return The new async connection, or NULL on invalid arguments or failure + * (fd is closed in the latter case). + */ cwist_websocket_async *cwist_websocket_async_attach_ex(int fd, cwist_reactor_t *reactor, cwist_ws_on_message_t on_message, cwist_ws_on_close_t on_close, @@ -960,6 +1184,21 @@ cwist_websocket_async *cwist_websocket_async_attach_ex(int fd, cwist_reactor_t * return ws; } +/** + * @brief Attach an upgraded fd to the reactor as an async WebSocket + * connection (no on_close callback variant). + * + * Thin boolean wrapper around cwist_websocket_async_attach_ex(). + * + * @param fd Upgraded socket fd (owned by the new connection on success). + * @param reactor Reactor to register the fd with. + * @param on_message Callback invoked per complete message. Must not be NULL. + * @param user_data Opaque pointer passed back to the callback. + * @param initial Bytes already read past the upgrade request; may be NULL. + * @param initial_len Number of bytes in initial. + * @retval true The connection was attached and the read slot armed. + * @retval false Invalid arguments or failure (fd is closed). + */ bool cwist_websocket_async_attach(int fd, cwist_reactor_t *reactor, cwist_ws_on_message_t on_message, void *user_data, const uint8_t *initial, size_t initial_len) { diff --git a/src/security/jwt/jwt.c b/src/security/jwt/jwt.c index 7cdfc9d1e..62b0c4745 100644 --- a/src/security/jwt/jwt.c +++ b/src/security/jwt/jwt.c @@ -53,9 +53,14 @@ static void b64url_encode(const unsigned char *src, size_t src_len, char *dst) { } /** - * Decode a Base64URL string. - * Returns heap-allocated buffer and sets *out_len, or NULL on error. - * Caller must free with cwist_free(). + * @brief Decode a Base64URL string. + * @param src Base64URL input; either standard or URL-safe alphabet, with optional '=' padding. + * @param src_len Length of @p src in bytes. + * @param out_len Set to the number of decoded bytes on success. + * @return Heap-allocated decoded buffer, or NULL on invalid input or allocation failure. + * @retval NULL Invalid input or allocation failure. + * + * Ownership of a non-NULL return passes to the caller; free with cwist_free(). */ static unsigned char *b64url_decode(const char *src, size_t src_len, size_t *out_len) { /* Accept either standard or url-safe alphabet; strip '=' padding. */ @@ -413,6 +418,17 @@ void cwist_jwt_claims_destroy(cwist_jwt_claims *claims) { * Sequenced JWT transport helpers * -------------------------------------------------------------------------- */ +/** + * @brief Split a JWT token into sequenced transport chunks. + * @param token Null-terminated JWT string to split. + * @param chunk_payload_size Maximum payload bytes per chunk; must be non-zero. + * @param out_count Set to the number of chunks produced. + * @return Heap-allocated array of @p out_count chunks, or NULL on invalid input, + * split failure, or allocation failure. + * + * Each chunk's @c data buffer is owned by the caller and must be released with + * cwist_jwt_chunks_free(). + */ cwist_jwt_chunk_t *cwist_jwt_split_chunks(const char *token, uint16_t chunk_payload_size, size_t *out_count) { if (!token || chunk_payload_size == 0 || !out_count) return NULL; @@ -445,12 +461,26 @@ cwist_jwt_chunk_t *cwist_jwt_split_chunks(const char *token, uint16_t chunk_payl return chunks; } +/** + * @brief Free a chunk array produced by cwist_jwt_split_chunks() or cwist_jwt_sign_chunks(). + * @param chunks Chunk array to free; NULL is accepted as a no-op. + * @param count Number of entries in @p chunks. + */ void cwist_jwt_chunks_free(cwist_jwt_chunk_t *chunks, size_t count) { if (!chunks) return; for (size_t i = 0; i < count; i++) cwist_free(chunks[i].data); cwist_free(chunks); } +/** + * @brief Reassemble a JWT token from sequenced transport chunks. + * @param chunks Array of sequenced chunks, in any order accepted by the assembler. + * @param count Number of entries in @p chunks. + * @return Heap-allocated null-terminated JWT string, or NULL when the chunk set is + * invalid/incomplete or assembly/allocation fails. + * + * The caller owns the returned string and must free it with cwist_free(). + */ char *cwist_jwt_join_chunks(const cwist_jwt_chunk_t *chunks, size_t count) { if (!chunks || count == 0) return NULL; @@ -485,6 +515,17 @@ char *cwist_jwt_join_chunks(const cwist_jwt_chunk_t *chunks, size_t count) { return token; } +/** + * @brief Sign a payload as an HS256 JWT and split it into sequenced transport chunks. + * @param payload_json Raw payload JSON string to sign. + * @param secret Shared HS256 signing secret. + * @param exp_seconds Lifetime in seconds to add when the payload lacks exp/iat. + * @param chunk_payload_size Maximum payload bytes per chunk; must be non-zero. + * @param out_count Set to the number of chunks produced. + * @return Heap-allocated chunk array, or NULL when signing or chunking fails. + * + * Free the result with cwist_jwt_chunks_free(). + */ cwist_jwt_chunk_t *cwist_jwt_sign_chunks(const char *payload_json, const char *secret, long exp_seconds, uint16_t chunk_payload_size, size_t *out_count) { @@ -499,6 +540,16 @@ cwist_jwt_chunk_t *cwist_jwt_sign_chunks(const char *payload_json, const char *s return chunks; } +/** + * @brief Reassemble sequenced chunks into a token, verify it, and return its claims. + * @param chunks Array of sequenced chunks carrying the JWT token. + * @param count Number of entries in @p chunks. + * @param secret Shared HS256 signing secret. + * @return Heap-allocated verified claims object, or NULL when assembly, parsing, + * signature verification, or claim validation fails. + * + * The caller owns the result and must release it with cwist_jwt_claims_destroy(). + */ cwist_jwt_claims *cwist_jwt_verify_chunks(const cwist_jwt_chunk_t *chunks, size_t count, const char *secret) { if (!chunks || count == 0 || !secret) return NULL; diff --git a/src/security/tls/ech.c b/src/security/tls/ech.c index 283bd0b78..051defe8b 100644 --- a/src/security/tls/ech.c +++ b/src/security/tls/ech.c @@ -10,6 +10,21 @@ #include #include +/** @brief Enable ECH (Encrypted Client Hello) support for an application. + * + * When built against OpenSSL 3.2 or newer with ECH support + * (SSL_OP_ENABLE_ECH defined), this sets the ECH option on the + * application's SSL context. On older OpenSSL versions this is a no-op + * that still succeeds. The ech_key and ech_dir arguments are currently + * accepted but not used. + * + * @param app Application whose SSL context is configured. Must not be + * NULL. + * @param ech_key Unused placeholder for a future ECH key configuration. + * @param ech_dir Unused placeholder for a future ECH directory source. + * @retval CWIST_ERROR_INVALID_PARAM @p app is NULL. + * @retval 0 Success; ECH was enabled or is unsupported on this build. + */ cwist_error_t cwist_app_use_ech(cwist_app *app, const char *ech_key, const char *ech_dir) { cwist_error_t err = make_error(CWIST_ERR_INT16); if (!app) { diff --git a/src/sys/app/assets.c b/src/sys/app/assets.c index 41b83e516..e51944d26 100644 --- a/src/sys/app/assets.c +++ b/src/sys/app/assets.c @@ -34,12 +34,14 @@ typedef struct cwist_asset_registry { _Atomic(struct cwist_asset *) head; } cwist_asset_registry; +/** Build an error result holding an int16 status code. */ static cwist_error_t asset_result(int16_t code) { cwist_error_t err = make_error(CWIST_ERR_INT16); err.error.err_i16 = code; return err; } +/** Allocate and return the three-part concatenation of `a`, `b`, and `c`; NULL on OOM. */ static char *join3(const char *a, const char *b, const char *c) { size_t la = strlen(a), lb = strlen(b), lc = strlen(c); char *out = (char *)cwist_alloc(la + lb + lc + 1); @@ -50,6 +52,7 @@ static char *join3(const char *a, const char *b, const char *c) { return out; } +/** Copy `url_prefix` into a normalized form (trailing '/' stripped); NULL if invalid. */ static char *normalize_prefix(const char *url_prefix) { if (!url_prefix || url_prefix[0] != '/') return NULL; size_t len = strlen(url_prefix); @@ -65,6 +68,7 @@ static char *normalize_prefix(const char *url_prefix) { return out; } +/** Whether `name` is a non-empty relative path of safe segments (no ".", "..", or empty segments). */ static bool name_is_valid(const char *name) { if (!name || !*name || name[0] == '/') return false; const char *seg = name; @@ -95,6 +99,7 @@ static size_t extension_offset(const char *name) { return (size_t)(dot - name); } +/** Guess a Content-Type from the file extension of `name` (case-insensitive). */ static const char *guess_content_type(const char *name) { static const struct { const char *ext; @@ -131,6 +136,7 @@ static const char *guess_content_type(const char *name) { return "application/octet-stream"; } +/** Write the hex of the first ASSET_HASH_HEX/2 bytes of the SHA-256 digest of `data` to `out`. */ static void content_hash(const void *data, size_t len, char out[ASSET_HASH_HEX + 1]) { static const char hex[] = "0123456789abcdef"; uint8_t digest[CWIST_SHA256_DIGEST_LEN]; @@ -145,6 +151,7 @@ static void content_hash(const void *data, size_t len, char out[ASSET_HASH_HEX + out[ASSET_HASH_HEX] = '\0'; } +/** Free an asset and all of its owned buffers. No-op for NULL. */ static void asset_free(struct cwist_asset *asset) { if (!asset) return; cwist_free(asset->name); @@ -155,6 +162,7 @@ static void asset_free(struct cwist_asset *asset) { cwist_free(asset); } +/** Fetch the app's registry, creating it (with the default prefix) when `create` is set. */ static cwist_asset_registry *registry_get(cwist_app *app, bool create) { if (app->assets || !create) return (cwist_asset_registry *)app->assets; cwist_asset_registry *reg = (cwist_asset_registry *)cwist_alloc(sizeof(*reg)); @@ -169,6 +177,17 @@ static cwist_asset_registry *registry_get(cwist_app *app, bool create) { return reg; } +/** + * @brief Set the URL prefix under which assets are served. + * + * Must be called before any asset is registered; fails once the registry is + * non-empty. + * @param app App instance; must not be NULL. + * @param url_prefix Absolute path such as "/assets" (trailing '/' stripped), + * or "/" for the root. + * @return Error result with code 0 on success, -1 on failure (NULL app, + * invalid prefix, or assets already registered). + */ cwist_error_t cwist_app_asset_prefix(cwist_app *app, const char *url_prefix) { if (!app) return asset_result(-1); char *prefix = normalize_prefix(url_prefix); @@ -218,6 +237,7 @@ static struct cwist_asset *asset_build(const cwist_asset_registry *reg, const ch return asset; } +/** Whether `value` contains no control bytes, making it safe as an HTTP header value. */ static bool header_value_is_valid(const char *value) { for (const unsigned char *p = (const unsigned char *)value; *p; p++) { if (*p < 0x20 || *p == 0x7f) return false; @@ -263,6 +283,20 @@ static cwist_error_t asset_publish(cwist_app *app, const char *name, unsigned ch return asset_result(0); } +/** + * @brief Publish an in-memory buffer as an asset. + * + * Copies `data`; on success the asset is immutable and served under its + * content-hashed name. Duplicate content is a no-op. + * @param app App instance; must not be NULL. + * @param name Logical asset name; must be a valid relative path. + * @param data Asset bytes; may be NULL only when `len` is 0. + * @param len Length of `data` in bytes. + * @param content_type Optional Content-Type override; if NULL a type is + * guessed from `name`. + * @return Error result with code 0 on success, -1 on invalid arguments or + * allocation failure. + */ cwist_error_t cwist_app_asset_add(cwist_app *app, const char *name, const void *data, size_t len, const char *content_type) { if (!app || !name_is_valid(name) || (!data && len > 0)) return asset_result(-1); @@ -275,6 +309,19 @@ cwist_error_t cwist_app_asset_add(cwist_app *app, const char *name, const void * return asset_publish(app, name, copy, len, content_type); } +/** + * @brief Read a file from disk and publish it as an asset. + * + * The file is read fully into memory; on success its content is served like + * an asset added with cwist_app_asset_add(). + * @param app App instance; must not be NULL. + * @param name Logical asset name; must be a valid relative path. + * @param path Filesystem path to read; must not be NULL. + * @param content_type Optional Content-Type override; if NULL a type is + * guessed from `name`. + * @return Error result with code 0 on success, -1 on invalid arguments, an + * unreadable file, or allocation failure. + */ cwist_error_t cwist_app_asset_add_file(cwist_app *app, const char *name, const char *path, const char *content_type) { if (!app || !name_is_valid(name) || !path) return asset_result(-1); @@ -312,6 +359,15 @@ cwist_error_t cwist_app_asset_add_file(cwist_app *app, const char *name, const c return asset_publish(app, name, buf, len, content_type); } +/** + * @brief Return the URL of the newest asset registered under `name`. + * + * The returned string is owned by the asset registry and stays valid until + * the app is destroyed. + * @param app App instance; must not be NULL. + * @param name Logical asset name. + * @return Asset URL, or NULL if no such asset exists. + */ const char *cwist_app_asset_url(cwist_app *app, const char *name) { if (!app || !name) return NULL; cwist_asset_registry *reg = registry_get(app, false); @@ -323,6 +379,16 @@ const char *cwist_app_asset_url(cwist_app *app, const char *name) { return NULL; } +/** + * @brief Match an HTTP request to a registered asset. + * + * Only GET and HEAD requests under the registry prefix are considered. A + * request for a hashed (immutable) URL takes precedence over a logical name. + * @param app App instance; must not be NULL. + * @param req Request to match; path must be present. + * @param out Filled with the matched asset and its immutability flag on match. + * @return true on a match, false otherwise. + */ bool cwist_assets_match(cwist_app *app, const cwist_http_request *req, cwist_asset_match *out) { if (!app || !app->assets || !req || !req->path || !req->path->data) return false; if (req->method != CWIST_HTTP_GET && req->method != CWIST_HTTP_HEAD) return false; @@ -367,6 +433,7 @@ static bool etag_matches(const char *if_none_match, const char *etag) { return false; } +/** Add a header to a response, returning whether it was added (error disposed). */ static bool add_header(cwist_http_response *res, const char *name, const char *value) { cwist_error_t err = cwist_http_header_add(&res->headers, name, value); bool ok = cwist_error_is_ok(&err); @@ -374,6 +441,17 @@ static bool add_header(cwist_http_response *res, const char *name, const char *v return ok; } +/** + * @brief Fill a response for a matched asset. + * + * Always sets ETag and Cache-Control; honors If-None-Match with 304 and an + * empty body. The body points directly at the asset data, which stays alive + * until the app is destroyed. + * @param req Request being answered. + * @param res Response to fill. + * @param match Match from cwist_assets_match(); if NULL or without an asset, + * the response status is set to 500. + */ void cwist_assets_respond(cwist_http_request *req, cwist_http_response *res, const cwist_asset_match *match) { if (!req || !res) return; @@ -404,6 +482,15 @@ void cwist_assets_respond(cwist_http_request *req, cwist_http_response *res, cwist_http_response_set_body_ptr(res, asset->data, asset->len); } +/** + * @brief Deep-copy a registry (used when an app is cloned). + * + * Copies the prefix and every asset, preserving newest-first order. On + * failure *dst stays NULL and no partial registry is left behind. + * @param dst Receives the new registry; set to NULL first. + * @param src Registry to copy; may be NULL, which is treated as empty. + * @return 0 on success, -1 on allocation failure. + */ int cwist_assets_clone(void **dst, const void *src) { *dst = NULL; if (!src) return 0; @@ -450,6 +537,7 @@ int cwist_assets_clone(void **dst, const void *src) { return 0; } +/** Free a registry and every asset in it. No-op for NULL. */ void cwist_assets_destroy(void *registry) { cwist_asset_registry *reg = (cwist_asset_registry *)registry; if (!reg) return; diff --git a/src/sys/app/big_dumb_reply.c b/src/sys/app/big_dumb_reply.c index b18aae86d..8b3b7cb5c 100644 --- a/src/sys/app/big_dumb_reply.c +++ b/src/sys/app/big_dumb_reply.c @@ -40,6 +40,14 @@ static const uint8_t BDR_KEY[16] = {0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x /* --- Blob lifetime -------------------------------------------------------- */ +/** + * @brief Epoch retire callback: release a blob and any buffer it owns. + * + * Reads the owned buffer and its free function before freeing, because for + * inline (learned) blobs the buffer aliases the blob header itself. + * + * @param ptr Blob to free; NULL is tolerated. + */ static void bdr_blob_free_cb(void *ptr) { bdr_blob_t *blob = (bdr_blob_t *)ptr; if (!blob) return; @@ -52,13 +60,19 @@ static void bdr_blob_free_cb(void *ptr) { if (mem != ptr) cwist_free(blob); } -/* Retire a swapped-out blob; the epoch guarantees no reader still serves it. */ +/** + * @brief Retire a swapped-out blob; the epoch guarantees no reader still serves it. + * @param blob Blob to retire; NULL is a no-op. + */ static void bdr_blob_retire(bdr_blob_t *blob) { if (blob) ttak_epoch_retire(blob, bdr_blob_free_cb); } -/* Learned blob: one allocation carries header and bytes (single copy from - * the caller-owned serialization, whose lifetime the cache cannot borrow). */ +/** + * @brief Build a learned blob: one allocation carries header and bytes (single copy from + * the caller-owned serialization, whose lifetime the cache cannot borrow). + * @return New blob on success, NULL on allocation failure. + */ static bdr_blob_t *bdr_blob_learn(const void *data, size_t len) { bdr_blob_t *blob = cwist_alloc(sizeof(bdr_blob_t) + len); if (!blob) return NULL; @@ -70,7 +84,12 @@ static bdr_blob_t *bdr_blob_learn(const void *data, size_t len) { return blob; } -/* Callback-provided blob: header only, the buffer pointer is swung as-is. */ +/** + * @brief Build a callback-provided blob: header only, the buffer pointer is swung as-is. + * @param mem Buffer owned by the caller/callback; freed via @p free_fn. + * @param free_fn Deallocator for @p mem; defaults to cwist_free when NULL. + * @return New blob on success, NULL on allocation failure. + */ static bdr_blob_t *bdr_blob_wrap(void *mem, size_t len, void (*free_fn)(void *)) { bdr_blob_t *blob = cwist_alloc(sizeof(bdr_blob_t)); if (!blob) return NULL; @@ -81,10 +100,20 @@ static bdr_blob_t *bdr_blob_wrap(void *mem, size_t len, void (*free_fn)(void *)) return blob; } +/** + * @brief Add @p len bytes to the cache's current byte counter (relaxed atomic). + * @param bdr Cache instance. + * @param len Number of bytes to add. + */ static void bdr_bytes_add(cwist_bdr_t *bdr, size_t len) { atomic_fetch_add_explicit(&bdr->current_bytes, len, memory_order_relaxed); } +/** + * @brief Subtract @p len bytes from the cache's current byte counter, clamping at zero. + * @param bdr Cache instance. + * @param len Number of bytes to subtract. + */ static void bdr_bytes_sub(cwist_bdr_t *bdr, size_t len) { size_t cur = atomic_load_explicit(&bdr->current_bytes, memory_order_relaxed); while (cur > 0) { @@ -95,7 +124,16 @@ static void bdr_bytes_sub(cwist_bdr_t *bdr, size_t len) { } } -/* Swap a new blob into an entry and retire whatever was published before. */ +/** + * @brief Swap a new blob into an entry and retire whatever was published before. + * + * Keeps the byte counter in sync and retires the predecessor through EBR so + * readers inside its epoch keep a valid blob. + * + * @param bdr Cache instance (byte accounting). + * @param entry Entry whose blob pointer is exchanged. + * @param blob New blob, or NULL to clear the entry. + */ static void bdr_entry_publish(cwist_bdr_t *bdr, bdr_entry_t *entry, bdr_blob_t *blob) { bdr_blob_t *old = atomic_exchange_explicit(&entry->blob, blob, memory_order_acq_rel); if (blob) bdr_bytes_add(bdr, blob->len); @@ -107,22 +145,53 @@ static void bdr_entry_publish(cwist_bdr_t *bdr, bdr_entry_t *entry, bdr_blob_t * /* --- Lookup helpers ------------------------------------------------------- */ +/** + * @brief Hash a request key (method first byte mixed into the path SipHash). + * @param method HTTP method string; only the first byte contributes. + * @param path NUL-terminated request path. + * @return 64-bit bucket hash. + */ static uint64_t bdr_hash(const char *method, const char *path) { uint64_t h = siphash24((const void *)path, strlen(path), BDR_KEY); h ^= (uint64_t)(method[0]); return h; } +/** + * @brief Hash a request key from a non-NUL-terminated path. + * @param method HTTP method string; only the first byte contributes. + * @param path Request path bytes (not required to be NUL-terminated). + * @param path_len Length of @p path in bytes. + * @return 64-bit bucket hash. + */ static uint64_t bdr_hash_n(const char *method, const char *path, size_t path_len) { uint64_t h = siphash24((const void *)path, path_len, BDR_KEY); h ^= (uint64_t)(method[0]); return h; } +/** + * @brief Hash response bytes (fingerprint used to detect content changes). + * @param data Response bytes. + * @param len Length of @p data in bytes. + * @return 64-bit response hash. + */ static uint64_t bdr_hash_data(const void *data, size_t len) { return siphash24(data, len, BDR_KEY); } +/** + * @brief Decide whether an entry should be retired by the janitor. + * + * An entry decays when its age exceeds @c max_entry_age_sec, or when it is + * stable and has accumulated at least @c revalidate_hits hits. + * + * @param bdr Cache instance (limits; may be NULL). + * @param entry Entry to inspect (may be NULL). + * @param now Current time. + * @retval true The entry should be retired. + * @retval false The entry stays live, or arguments are NULL. + */ static bool bdr_entry_should_decay(const cwist_bdr_t *bdr, const bdr_entry_t *entry, time_t now) { if (!bdr || !entry) return false; int64_t created = atomic_load_explicit(&entry->created_at, memory_order_relaxed); @@ -136,8 +205,16 @@ static bool bdr_entry_should_decay(const cwist_bdr_t *bdr, const bdr_entry_t *en return false; } -/* Walk a bucket chain; entries are tombstoned rather than freed, so the - * walk is safe without locks as long as loads are atomic. */ +/** + * @brief Walk a bucket chain for a precomputed request hash. + * + * Entries are tombstoned rather than freed, so the walk is safe without + * locks as long as loads are atomic. Retired entries are skipped. + * + * @param bdr Cache instance. + * @param req_h Precomputed request hash. + * @return Matching live entry, or NULL if none. + */ static bdr_entry_t *bdr_find(cwist_bdr_t *bdr, uint64_t req_h) { size_t idx = req_h % bdr->bucket_count; bdr_entry_t *curr = atomic_load_explicit(&bdr->buckets[idx], memory_order_acquire); @@ -151,8 +228,18 @@ static bdr_entry_t *bdr_find(cwist_bdr_t *bdr, uint64_t req_h) { return NULL; } -/* Insert a fresh candidate entry at the bucket head, lock-free. Returns - * the entry that ended up reachable (ours, or the winner of a CAS race). */ +/** + * @brief Look up an entry by request hash, inserting a fresh candidate at the + * bucket head if absent (lock-free CAS push). + * + * On a CAS race with a concurrent insert of the same key, the losing entry is + * freed and the winner is returned. + * + * @param bdr Cache instance. + * @param req_h Precomputed request hash. + * @return The entry that ended up reachable (ours, or the winner of a CAS race), + * or NULL on allocation failure. + */ static bdr_entry_t *bdr_find_or_insert(cwist_bdr_t *bdr, uint64_t req_h) { bdr_entry_t *found = bdr_find(bdr, req_h); if (found) return found; @@ -187,6 +274,18 @@ static bdr_entry_t *bdr_find_or_insert(cwist_bdr_t *bdr, uint64_t req_h) { /* --- Janitor (mutex-serialized, runs every BDR_JANITOR_PERIOD learns) ----- */ +/** + * @brief Unlink an entry from its bucket chain and push it onto the retired list. + * + * Tombstones the entry (readers may still hold it), clears its blob, and moves + * it to @c retired_entries for deferred reclamation at destroy. Must be called + * with the janitor lock held or during teardown. + * + * @param bdr Cache instance. + * @param idx Bucket index of the entry. + * @param prev Predecessor in the chain, or NULL if @p entry is the bucket head. + * @param entry Entry to retire. + */ static void bdr_retire_entry(cwist_bdr_t *bdr, size_t idx, bdr_entry_t *prev, bdr_entry_t *entry) { atomic_store_explicit(&entry->retired, true, memory_order_release); if (prev) { @@ -202,6 +301,14 @@ static void bdr_retire_entry(cwist_bdr_t *bdr, size_t idx, bdr_entry_t *prev, bd bdr->retired_entries = entry; } +/** + * @brief Incremental GC sweep: visit @p steps buckets and retire decayed entries. + * + * Advances @c gc_cursor so successive calls cover the table over time. + * + * @param bdr Cache instance. + * @param steps Number of buckets to visit; 0 is a no-op. + */ static void bdr_sweep(cwist_bdr_t *bdr, size_t steps) { if (!bdr || bdr->bucket_count == 0 || steps == 0) return; time_t now = time(NULL); @@ -223,6 +330,16 @@ static void bdr_sweep(cwist_bdr_t *bdr, size_t steps) { } } +/** + * @brief Find the oldest live, blob-backed entry and retire it. + * + * Scans the whole table; used to enforce the @c max_bytes byte budget. + * + * @param bdr Cache instance. + * @param now Current time (baseline for the oldest comparison). + * @retval true An entry was retired. + * @retval false No eligible entry exists. + */ static bool bdr_trim_oldest(cwist_bdr_t *bdr, time_t now) { size_t victim_idx = SIZE_MAX; bdr_entry_t *victim = NULL; @@ -253,6 +370,17 @@ static bool bdr_trim_oldest(cwist_bdr_t *bdr, time_t now) { return true; } +/** + * @brief Spill the in-RAM cache to a SQLite fallback database when RAM is critical. + * + * When free RAM drops below the 64 MiB threshold, copies every stable entry's + * blob into "cwist_bdr_fallback.db", retires all live entries, and flips the + * cache into disk mode so subsequent reads miss and writes go to SQLite. + * No-op in the WASM build and once disk mode is already active. Must be + * called with the janitor lock held. + * + * @param bdr Cache instance. + */ static void bdr_check_ram(cwist_bdr_t *bdr) { #ifdef __EMSCRIPTEN__ /* No SQLite disk fallback in the WASM core; the in-RAM cache stands alone. */ @@ -306,6 +434,16 @@ static void bdr_check_ram(cwist_bdr_t *bdr) { #endif } +/** + * @brief Periodic janitor hook, invoked from the lock-free learn path. + * + * Runs every @c BDR_JANITOR_PERIOD puts: checks for a RAM-critical disk spill, + * sweeps a few buckets for decayed entries, and trims the oldest entries while + * the byte budget is exceeded. Serialized with a trylock so a contended tick + * is skipped rather than blocking the learn path. + * + * @param bdr Cache instance. + */ static void bdr_janitor_tick(cwist_bdr_t *bdr) { uint64_t n = atomic_fetch_add_explicit(&bdr->put_count, 1, memory_order_relaxed) + 1; if (n % BDR_JANITOR_PERIOD != 0) return; @@ -323,6 +461,14 @@ static void bdr_janitor_tick(cwist_bdr_t *bdr) { /* --- Lifecycle ------------------------------------------------------------ */ +/** + * @brief Create a reply cache instance with default limits. + * + * Initializes the mutex, the bucket table, and the default tuning values + * (32 MiB byte budget, 300 s entry TTL, 100000 revalidate hits). + * + * @return New cache instance on success, NULL on allocation or mutex failure. + */ cwist_bdr_t *cwist_bdr_create(void) { cwist_bdr_t *bdr = cwist_alloc(sizeof(cwist_bdr_t)); if (!bdr) return NULL; @@ -350,6 +496,15 @@ cwist_bdr_t *cwist_bdr_create(void) { return bdr; } +/** + * @brief Destroy a cache instance and release all its resources. + * + * Frees every live and retired entry and its blob, the bucket table, and the + * disk fallback database (including the "cwist_bdr_fallback.db" file). Safe + * to call with NULL. + * + * @param bdr Cache instance to destroy, or NULL. + */ void cwist_bdr_destroy(cwist_bdr_t *bdr) { if (!bdr) return; pthread_mutex_lock(&bdr->lock); @@ -384,11 +539,20 @@ void cwist_bdr_destroy(cwist_bdr_t *bdr) { /* --- Read path ------------------------------------------------------------ */ -/* Shared read core: epoch-protected hit validation for a precomputed - * request hash, optionally starting from a caller-cached entry hint. The - * hint is re-validated (retirement + key match) under the epoch; a stale - * hint degrades to a full bucket walk, never to a wrong serve. Returns the - * blob bytes with the pin held, or NULL with no pin held. */ +/** + * @brief Shared read core: epoch-protected hit validation for a precomputed + * request hash, optionally starting from a caller-cached entry hint. + * + * The hint is re-validated (retirement + key match) under the epoch; a stale + * hint degrades to a full bucket walk, never to a wrong serve. + * + * @param bdr Cache instance. + * @param req_h Precomputed request hash. + * @param hint Candidate entry from a cursor, or NULL for a full lookup. + * @param out_len Receives the blob length on a hit (may be NULL). + * @param out_pin Receives the pinned blob; released with cwist_bdr_unpin(). + * @return Blob bytes with the pin held, or NULL with no pin held. + */ static const void *bdr_serve_hit(cwist_bdr_t *bdr, uint64_t req_h, bdr_entry_t *hint, size_t *out_len, bdr_blob_t **out_pin) { ttak_epoch_enter(); @@ -433,6 +597,19 @@ static const void *bdr_serve_hit(cwist_bdr_t *bdr, uint64_t req_h, bdr_entry_t * return blob->data; } +/** + * @brief Look up a cached GET reply, returning epoch-pinned bytes. + * + * Only GET requests are served, and only while the cache is in RAM mode. The + * caller must release the pin with cwist_bdr_unpin() once done reading. + * + * @param bdr Cache instance. + * @param method HTTP method; must be "GET". + * @param path NUL-terminated request path. + * @param out_len Receives the reply length on a hit (may be NULL). + * @param out_pin Receives the pin to pass to cwist_bdr_unpin(). + * @return Reply bytes pinned for the caller, or NULL on miss/invalid input. + */ const void *cwist_bdr_get_pinned(cwist_bdr_t *bdr, const char *method, const char *path, size_t *out_len, bdr_blob_t **out_pin) { if (!bdr || !method || !path || !out_pin) return NULL; @@ -442,6 +619,23 @@ const void *cwist_bdr_get_pinned(cwist_bdr_t *bdr, const char *method, const cha return bdr_serve_hit(bdr, bdr_hash(method, path), NULL, out_len, out_pin); } +/** + * @brief Cursor-accelerated variant of cwist_bdr_get_pinned() for non-NUL-terminated paths. + * + * When the cursor's cached entry still matches the request it is used as a + * lookup hint; a stale hint degrades to a full bucket walk. On a hit the + * cursor is refreshed to point at the served entry, so repeated reads of the + * same path skip the chain walk. + * + * @param bdr Cache instance. + * @param method HTTP method; must be "GET". + * @param path Request path bytes (not required to be NUL-terminated). + * @param path_len Length of @p path in bytes. + * @param out_len Receives the reply length on a hit (may be NULL). + * @param out_pin Receives the pin to pass to cwist_bdr_unpin(). + * @param cursor Reusable lookup cursor, updated on every call. + * @return Reply bytes pinned for the caller, or NULL on miss/invalid input. + */ const void *cwist_bdr_get_pinned_cursor(cwist_bdr_t *bdr, const char *method, const char *path, size_t path_len, size_t *out_len, bdr_blob_t **out_pin, cwist_bdr_cursor_t *cursor) { @@ -483,11 +677,31 @@ const void *cwist_bdr_get_pinned_cursor(cwist_bdr_t *bdr, const char *method, co return data; } +/** + * @brief Release a pin obtained from a cwist_bdr_get_pinned*() call. + * + * The pin is the EBR epoch itself, so this simply leaves the epoch. + * + * @param pin Pin to release (ignored beyond symmetry with the get calls). + */ void cwist_bdr_unpin(bdr_blob_t *pin) { (void)pin; /* The pin is the epoch itself. */ ttak_epoch_exit(); } +/** + * @brief Look up a cached GET reply and return a borrowed pointer to its bytes. + * + * Convenience wrapper over cwist_bdr_get_pinned() that unpins immediately, so + * the returned pointer is only valid until the next janitor activity that + * could retire the entry; prefer the pinned API for anything but a memcpy. + * + * @param bdr Cache instance. + * @param method HTTP method; must be "GET". + * @param path NUL-terminated request path. + * @param out_len Receives the reply length on a hit (may be NULL). + * @return Reply bytes (unpinned), or NULL on miss/invalid input. + */ const void *cwist_bdr_get(cwist_bdr_t *bdr, const char *method, const char *path, size_t *out_len) { bdr_blob_t *pin = NULL; const void *data = cwist_bdr_get_pinned(bdr, method, path, out_len, &pin); @@ -495,6 +709,18 @@ const void *cwist_bdr_get(cwist_bdr_t *bdr, const char *method, const char *path return data; } +/** + * @brief Look up a cached GET reply and return an owned copy of its bytes. + * + * Unlike cwist_bdr_get(), the returned buffer is allocated with cwist_alloc() + * and survives cache eviction; the caller frees it with cwist_free(). + * + * @param bdr Cache instance. + * @param method HTTP method; must be "GET". + * @param path NUL-terminated request path. + * @param out_len Receives the copy length on a hit (may be NULL). + * @return Newly allocated copy of the reply, or NULL on miss/invalid input/allocation failure. + */ void *cwist_bdr_copy_get(cwist_bdr_t *bdr, const char *method, const char *path, size_t *out_len) { bdr_blob_t *pin = NULL; size_t len = 0; @@ -513,6 +739,17 @@ void *cwist_bdr_copy_get(cwist_bdr_t *bdr, const char *method, const char *path, /* --- Learn path (lock-free) ------------------------------------------------ */ +/** + * @brief Write a reply into the SQLite fallback database (disk mode). + * + * Must be called only while the cache is in disk mode; takes the context + * mutex for the duration of the statement. + * + * @param bdr Cache instance (disk_db must be open). + * @param req_h Precomputed request hash (primary key). + * @param data Response bytes. + * @param len Length of @p data in bytes. + */ static void cwist_bdr_put_disk(cwist_bdr_t *bdr, uint64_t req_h, const void *data, size_t len) { pthread_mutex_lock(&bdr->lock); sqlite3_stmt *stmt; @@ -525,6 +762,20 @@ static void cwist_bdr_put_disk(cwist_bdr_t *bdr, uint64_t req_h, const void *dat pthread_mutex_unlock(&bdr->lock); } +/** + * @brief Learn a reply observation into the cache (lock-free fast path). + * + * Only GET replies are tracked. An entry stabilizes once the same response + * bytes are seen twice in a row; a content change under a stable entry demotes + * it back to candidate. In disk mode the observation is written to SQLite + * instead. Triggers the janitor tick periodically. + * + * @param bdr Cache instance. + * @param method HTTP method; must be "GET". + * @param path NUL-terminated request path. + * @param data Observed response bytes. + * @param len Length of @p data in bytes. + */ void cwist_bdr_put(cwist_bdr_t *bdr, const char *method, const char *path, const void *data, size_t len) { if (!bdr || !method || !path || !data || len == 0) return; @@ -574,6 +825,19 @@ void cwist_bdr_put(cwist_bdr_t *bdr, const char *method, const char *path, const bdr_janitor_tick(bdr); } +/** + * @brief Publish a reply as immediately stable, bypassing the learning phase. + * + * For responses that are known to be constant; stores the bytes and marks the + * entry stable in one shot. In disk mode the observation is written to + * SQLite instead. Triggers the janitor tick periodically. + * + * @param bdr Cache instance. + * @param method HTTP method; must be "GET". + * @param path NUL-terminated request path. + * @param data Response bytes to cache. + * @param len Length of @p data in bytes. + */ void cwist_bdr_put_fixed(cwist_bdr_t *bdr, const char *method, const char *path, const void *data, size_t len) { if (!bdr || !method || !path || !data || len == 0) return; @@ -600,6 +864,24 @@ void cwist_bdr_put_fixed(cwist_bdr_t *bdr, const char *method, const char *path, bdr_janitor_tick(bdr); } +/** + * @brief Publish a stable reply together with a hit-time revalidation hook. + * + * On every subsequent hit the hook is invoked and, if it reports fresh + * bytes, the entry's blob is swapped to the callback-provided buffer through + * one pointer exchange. The hook is published before the blob so a reader + * never observes a revalidatable blob without its hook. In disk mode the + * observation is written to SQLite instead (hook not persisted). Triggers + * the janitor tick periodically. + * + * @param bdr Cache instance. + * @param method HTTP method; must be "GET". + * @param path NUL-terminated request path. + * @param data Response bytes to cache. + * @param len Length of @p data in bytes. + * @param fn Revalidation callback invoked on hits. + * @param arg Opaque argument passed to @p fn. + */ void cwist_bdr_put_revalidatable(cwist_bdr_t *bdr, const char *method, const char *path, const void *data, size_t len, cwist_bdr_revalidate_fn fn, void *arg) { @@ -633,6 +915,17 @@ void cwist_bdr_put_revalidatable(cwist_bdr_t *bdr, const char *method, const cha bdr_janitor_tick(bdr); } +/** + * @brief Update cache limits; zero arguments leave the corresponding limit unchanged. + * + * Takes the context mutex so the new limits are not torn against a running + * janitor tick. + * + * @param bdr Cache instance. + * @param max_bytes New byte budget; 0 keeps the current value. + * @param max_entry_age_sec New entry TTL in seconds; 0 keeps the current value. + * @param revalidate_hits New stability hit threshold; 0 keeps the current value. + */ void cwist_bdr_set_limits(cwist_bdr_t *bdr, size_t max_bytes, time_t max_entry_age_sec, uint64_t revalidate_hits) { if (!bdr) return; diff --git a/src/sys/app/compress.c b/src/sys/app/compress.c index afc476cad..d44583dbd 100644 --- a/src/sys/app/compress.c +++ b/src/sys/app/compress.c @@ -21,17 +21,31 @@ static const cwist_compress_backend *g_backends[CWIST_MAX_BACKENDS]; static int g_backend_count = 0; static size_t g_compress_min_size = 0; +/** + * @brief Register a compression backend for use by the middleware. + * @param backend Backend to append to the registry. Ignored if NULL or if the + * registry is already full (CWIST_MAX_BACKENDS entries). + */ void cwist_compress_register_backend(const cwist_compress_backend *backend) { if (!backend || g_backend_count >= CWIST_MAX_BACKENDS) return; g_backends[g_backend_count++] = backend; } +/** + * @brief Remove all registered compression backends. + */ void cwist_compress_unregister_all(void) { g_backend_count = 0; } /* --- zlib/gzip backend --- */ +/** + * @brief Initialize a gzip (zlib deflate with gzip wrapper) stream. + * @param state Out param receiving the allocated stream on success. + * @retval 0 on success, with *state set. + * @retval -1 on allocation or deflateInit2 failure (state is freed). + */ static int zlib_gzip_init(void **state) { z_stream *zs = (z_stream *)calloc(1, sizeof(z_stream)); if (!zs) return -1; @@ -44,6 +58,12 @@ static int zlib_gzip_init(void **state) { return 0; } +/** + * @brief Initialize a raw deflate (zlib) stream. + * @param state Out param receiving the allocated stream on success. + * @retval 0 on success, with *state set. + * @retval -1 on allocation or deflateInit2 failure (state is freed). + */ static int zlib_deflate_init(void **state) { z_stream *zs = (z_stream *)calloc(1, sizeof(z_stream)); if (!zs) return -1; @@ -55,6 +75,17 @@ static int zlib_deflate_init(void **state) { return 0; } +/** + * @brief Compress one body chunk through a zlib stream. + * @param state zlib stream initialized by zlib_*_init. + * @param in Input chunk. + * @param in_len Length of @p in. + * @param out Output buffer. + * @param in,out out_len On entry, capacity of @p out; on success, bytes written. + * @param flush Non-zero when this is the final chunk of the body. + * @retval 0 on success. + * @retval -1 if the chunk was only partially consumed or the stream errored. + */ static int zlib_compress(void *state, const char *in, size_t in_len, char *out, size_t *out_len, int flush) { z_stream *zs = (z_stream *)state; @@ -76,6 +107,14 @@ static int zlib_compress(void *state, const char *in, size_t in_len, char *out, return 0; } +/** + * @brief Emit the zlib stream trailer (final bytes) and verify completion. + * @param state zlib stream. + * @param out Output buffer for the trailing bytes. + * @param in,out out_len On entry, capacity of @p out; on success, bytes written. + * @retval 0 on success. + * @retval -1 if the stream did not end (e.g. trailer did not fit). + */ static int zlib_finish(void *state, char *out, size_t *out_len) { z_stream *zs = (z_stream *)state; zs->next_in = NULL; @@ -90,6 +129,10 @@ static int zlib_finish(void *state, char *out, size_t *out_len) { return 0; } +/** + * @brief Free a zlib stream and its resources. Safe on NULL. + * @param state Stream allocated by zlib_*_init. + */ static void zlib_cleanup(void *state) { z_stream *zs = (z_stream *)state; if (zs) { @@ -114,10 +157,18 @@ static const cwist_compress_backend cwist_backend_deflate = { .cleanup = zlib_cleanup, }; +/** + * @brief Get the built-in gzip backend. + * @return Pointer to the static gzip backend descriptor. + */ const cwist_compress_backend *cwist_compress_backend_gzip(void) { return &cwist_backend_gzip; } +/** + * @brief Get the built-in deflate backend. + * @return Pointer to the static deflate backend descriptor. + */ const cwist_compress_backend *cwist_compress_backend_deflate(void) { return &cwist_backend_deflate; } @@ -133,6 +184,12 @@ typedef struct { size_t out_pos; } cwist_brotli_state_t; +/** + * @brief Allocate a Brotli backend state. + * @param state Out param receiving the allocated state on success. + * @retval 0 on success, with *state set. + * @retval -1 on allocation failure. + */ static int brotli_init(void **state) { cwist_brotli_state_t *bs = (cwist_brotli_state_t *)calloc(1, sizeof(*bs)); if (!bs) return -1; @@ -140,6 +197,13 @@ static int brotli_init(void **state) { return 0; } +/** + * @brief Grow the input accumulation buffer if it cannot hold @p need more bytes. + * @param bs Brotli state. + * @param need Additional bytes that must fit in the accumulator. + * @retval 0 if the accumulator has capacity for @p need more bytes. + * @retval -1 if @p bs is NULL or reallocation failed (state unchanged on failure). + */ static int brotli_ensure_accum(cwist_brotli_state_t *bs, size_t need) { if (!bs) return -1; size_t required = bs->accum_len + need; @@ -154,6 +218,23 @@ static int brotli_ensure_accum(cwist_brotli_state_t *bs, size_t need) { return 0; } +/** + * @brief Compress one body chunk with Brotli. + * + * Without flush, input is appended to an internal accumulator and no output is + * produced. With flush, the accumulated input plus the final chunk is + * compressed into an internal buffer, and as much of the result as fits is + * copied to @p out; the remainder is drained via brotli_finish(). + * + * @param state Brotli state. + * @param in Input chunk (may be NULL when nothing new to add). + * @param in_len Length of @p in. + * @param out Caller output buffer. + * @param in,out out_len On entry, capacity of @p out; on success, bytes copied. + * @param flush Non-zero for the final chunk of the body. + * @retval 0 on success. + * @retval -1 on invalid arguments or allocation/compression failure. + */ static int brotli_compress(void *state, const char *in, size_t in_len, char *out, size_t *out_len, int flush) { cwist_brotli_state_t *bs = (cwist_brotli_state_t *)state; @@ -221,6 +302,14 @@ static int brotli_compress(void *state, const char *in, size_t in_len, char *out return 0; } +/** + * @brief Drain remaining compressed output from the internal buffer. + * @param state Brotli state. + * @param out Caller output buffer. + * @param in,out out_len On entry, capacity of @p out; on success, bytes copied. + * @retval 0 on success (including when the internal buffer is fully drained). + * @retval -1 on invalid arguments. + */ static int brotli_finish(void *state, char *out, size_t *out_len) { cwist_brotli_state_t *bs = (cwist_brotli_state_t *)state; if (!bs || !out || !out_len) return -1; @@ -233,6 +322,10 @@ static int brotli_finish(void *state, char *out, size_t *out_len) { return 0; } +/** + * @brief Free a Brotli state and all its buffers. Safe on NULL. + * @param state Brotli state allocated by brotli_init. + */ static void brotli_cleanup(void *state) { cwist_brotli_state_t *bs = (cwist_brotli_state_t *)state; if (!bs) return; @@ -249,6 +342,10 @@ static const cwist_compress_backend cwist_backend_brotli = { .cleanup = brotli_cleanup, }; +/** + * @brief Get the built-in Brotli backend. + * @return Pointer to the static Brotli backend descriptor. + */ const cwist_compress_backend *cwist_compress_backend_brotli(void) { return &cwist_backend_brotli; } @@ -265,6 +362,12 @@ typedef struct { size_t out_pos; } cwist_zstd_state_t; +/** + * @brief Allocate and initialize a zstd backend state (compression stream). + * @param state Out param receiving the allocated state on success. + * @retval 0 on success, with *state set. + * @retval -1 on allocation or stream initialization failure (resources freed). + */ static int zstd_init(void **state) { cwist_zstd_state_t *zs = (cwist_zstd_state_t *)calloc(1, sizeof(*zs)); if (!zs) return -1; @@ -283,6 +386,13 @@ static int zstd_init(void **state) { return 0; } +/** + * @brief Grow the input accumulation buffer if it cannot hold @p need more bytes. + * @param zs zstd state. + * @param need Additional bytes that must fit in the accumulator. + * @retval 0 if the accumulator has capacity for @p need more bytes. + * @retval -1 if reallocation failed (state unchanged on failure). + */ static int zstd_ensure_accum(cwist_zstd_state_t *zs, size_t need) { size_t required = zs->accum_len + need; if (required > zs->accum_cap) { @@ -296,6 +406,23 @@ static int zstd_ensure_accum(cwist_zstd_state_t *zs, size_t need) { return 0; } +/** + * @brief Compress one body chunk with zstd. + * + * Without flush, input is appended to an internal accumulator and no output is + * produced. With flush, the accumulated input plus the final chunk is + * compressed into an internal buffer, and as much of the result as fits is + * copied to @p out; the remainder is drained via zstd_finish(). + * + * @param state zstd state. + * @param in Input chunk (may be NULL when nothing new to add). + * @param in_len Length of @p in. + * @param out Caller output buffer. + * @param in,out out_len On entry, capacity of @p out; on success, bytes copied. + * @param flush Non-zero for the final chunk of the body. + * @retval 0 on success. + * @retval -1 on invalid arguments or allocation/compression failure. + */ static int zstd_compress(void *state, const char *in, size_t in_len, char *out, size_t *out_len, int flush) { cwist_zstd_state_t *zs = (cwist_zstd_state_t *)state; @@ -349,6 +476,14 @@ static int zstd_compress(void *state, const char *in, size_t in_len, char *out, return 0; } +/** + * @brief Drain remaining compressed output from the internal buffer. + * @param state zstd state. + * @param out Caller output buffer. + * @param in,out out_len On entry, capacity of @p out; on success, bytes copied. + * @retval 0 on success (including when the internal buffer is fully drained). + * @retval -1 on invalid arguments. + */ static int zstd_finish(void *state, char *out, size_t *out_len) { cwist_zstd_state_t *zs = (cwist_zstd_state_t *)state; if (!zs || !out || !out_len) return -1; @@ -360,6 +495,10 @@ static int zstd_finish(void *state, char *out, size_t *out_len) { return 0; } +/** + * @brief Free a zstd state, its stream, and its buffers. Safe on NULL. + * @param state zstd state allocated by zstd_init. + */ static void zstd_cleanup(void *state) { cwist_zstd_state_t *zs = (cwist_zstd_state_t *)state; if (!zs) return; @@ -377,11 +516,22 @@ static const cwist_compress_backend cwist_backend_zstd = { .cleanup = zstd_cleanup, }; +/** + * @brief Get the built-in zstd backend. + * @return Pointer to the static zstd backend descriptor. + */ const cwist_compress_backend *cwist_compress_backend_zstd(void) { return &cwist_backend_zstd; } /* --- Middleware helpers --- */ +/** + * @brief Case-insensitive substring search. + * @param haystack String to search in. + * @param needle Substring to find. + * @return Non-zero if @p needle occurs in @p haystack, ignoring ASCII case; + * an empty needle matches. Returns 0 if either argument is NULL. + */ static int str_contains_ci(const char *haystack, const char *needle) { if (!haystack || !needle) return 0; size_t hlen = strlen(haystack); @@ -402,6 +552,12 @@ static int str_contains_ci(const char *haystack, const char *needle) { return 0; } +/** + * @brief Select the first registered backend whose encoding name appears in + * the Accept-Encoding header value (case-insensitive). + * @param accept_encoding Value of the Accept-Encoding header (may be NULL). + * @return Matching backend, or NULL if none matches or the header is absent. + */ static const cwist_compress_backend *select_backend(const char *accept_encoding) { if (!accept_encoding) return NULL; /* Prefer gzip, then deflate */ @@ -413,6 +569,15 @@ static const cwist_compress_backend *select_backend(const char *accept_encoding) return NULL; } +/** + * @brief Remove all response headers matching @p key (case-insensitive). + * + * Frees the header's key and value strings, and frees the node itself unless + * it is arena-owned. + * + * @param in,out head Pointer to the header list head; updated in place. + * @param key Header name to remove. + */ static void remove_header(cwist_http_header_node **head, const char *key) { cwist_http_header_node **curr = head; while (*curr) { @@ -430,6 +595,20 @@ static void remove_header(cwist_http_header_node **head, const char *key) { } } +/** + * @brief Compression middleware handler. + * + * Runs the next handler, then compresses the response body if the client + * accepts a registered encoding and the response is eligible: not deferred, + * not empty, not a pointer/file-stream body, status in the 2xx range, and at + * least g_compress_min_size bytes. On success the body is replaced by the + * compressed data and Content-Encoding is set (Content-Length is removed). + * On any compression failure the original body is left untouched. + * + * @param req Request (Accept-Encoding selects the backend). + * @param res Response produced by @p next; modified in place on success. + * @param next Next handler in the chain. + */ static void cwist_mw_compress_handler(cwist_http_request *req, cwist_http_response *res, cwist_handler_func next) { const char *accept = cwist_http_header_get(req->headers, "Accept-Encoding"); @@ -525,6 +704,11 @@ static void cwist_mw_compress_handler(cwist_http_request *req, cwist_http_respon cwist_free(out_buf); } +/** + * @brief Create the response compression middleware. + * @param min_body_size Minimum body size, in bytes, for compression to apply. + * @return Middleware handler that compresses eligible responses. + */ cwist_middleware_func cwist_mw_compress(size_t min_body_size) { g_compress_min_size = min_body_size; return cwist_mw_compress_handler; diff --git a/src/sys/app/config.c b/src/sys/app/config.c index c8cef5df1..588f7191f 100644 --- a/src/sys/app/config.c +++ b/src/sys/app/config.c @@ -12,6 +12,10 @@ #define CWIST_CONFIG_BUCKETS 64 +/** @brief Hash a config key using the djb2 algorithm. + * @param key NUL-terminated key string. + * @return Hash value, used modulo the bucket count. + */ static size_t cwist_config_hash(const char *key) { size_t hash = 5381; int c; @@ -19,6 +23,9 @@ static size_t cwist_config_hash(const char *key) { return hash; } +/** @brief Create an empty config store. + * @return Newly allocated config on success, NULL if allocation fails. + */ cwist_config *cwist_config_create(void) { cwist_config *cfg = (cwist_config *)cwist_alloc(sizeof(cwist_config)); if (!cfg) return NULL; @@ -32,6 +39,9 @@ cwist_config *cwist_config_create(void) { return cfg; } +/** @brief Free a config store and all of its key/value entries. + * @param cfg Config to destroy; NULL is accepted and ignored. + */ void cwist_config_destroy(cwist_config *cfg) { if (!cfg) return; for (size_t i = 0; i < cfg->bucket_count; i++) { @@ -48,6 +58,11 @@ void cwist_config_destroy(cwist_config *cfg) { cwist_free(cfg); } +/** @brief Set a config key to a value, replacing any existing entry. + * @param cfg Config store; NULL is accepted and ignored. + * @param key Key to set; NULL is accepted and ignored. A copy is stored. + * @param value Value to set; NULL is stored as an empty string. A copy is stored. + */ void cwist_config_set(cwist_config *cfg, const char *key, const char *value) { if (!cfg || !key) return; size_t idx = cwist_config_hash(key) % cfg->bucket_count; @@ -67,6 +82,13 @@ void cwist_config_set(cwist_config *cfg, const char *key, const char *value) { cfg->buckets[idx] = bucket; } +/** @brief Look up the value for a key. + * @param cfg Config store; NULL is accepted. + * @param key Key to look up; NULL is accepted. + * @return Pointer to the stored value string, or NULL if the key is not found + * or the arguments are invalid. The pointer is owned by the config and + * must not be freed. + */ const char *cwist_config_get(cwist_config *cfg, const char *key) { if (!cfg || !key) return NULL; size_t idx = cwist_config_hash(key) % cfg->bucket_count; @@ -80,6 +102,13 @@ const char *cwist_config_get(cwist_config *cfg, const char *key) { return NULL; } +/** @brief Get a config value parsed as an integer. + * @param cfg Config store. + * @param key Key to look up. + * @param default_val Value returned when the key is missing or is not a + * well-formed base-10 integer. + * @return The parsed integer, or @p default_val on missing/malformed values. + */ int cwist_config_get_int(cwist_config *cfg, const char *key, int default_val) { const char *val = cwist_config_get(cfg, key); if (!val) return default_val; @@ -89,6 +118,15 @@ int cwist_config_get_int(cwist_config *cfg, const char *key, int default_val) { return (int)l; } +/** @brief Get a config value parsed as a boolean. + * Accepted true spellings (case-insensitive): "true", "1", "yes". + * Accepted false spellings (case-insensitive): "false", "0", "no". + * @param cfg Config store. + * @param key Key to look up. + * @param default_val Value returned when the key is missing or matches no + * recognized spelling. + * @return The parsed boolean, or @p default_val on unrecognized values. + */ bool cwist_config_get_bool(cwist_config *cfg, const char *key, bool default_val) { const char *val = cwist_config_get(cfg, key); if (!val) return default_val; @@ -99,6 +137,13 @@ bool cwist_config_get_bool(cwist_config *cfg, const char *key, bool default_val) return default_val; } +/** @brief Load key/value pairs from the process environment into the config. + * Entries without '=' are skipped. If @p prefix is non-NULL and non-empty, + * only variables starting with the prefix are loaded; the prefix is kept in + * the stored key. + * @param cfg Config store; NULL is accepted and ignored. + * @param prefix Optional key prefix filter; NULL or empty matches all. + */ void cwist_config_load_env(cwist_config *cfg, const char *prefix) { if (!cfg) return; extern char **environ; @@ -117,6 +162,14 @@ void cwist_config_load_env(cwist_config *cfg, const char *prefix) { } } +/** @brief Load key/value pairs from a simple properties-style file. + * Each non-blank, non-comment line must be `key=value`. Leading/trailing + * whitespace around keys and values is trimmed, and a value wrapped in double + * quotes is unquoted. Lines starting with '#' and lines without '=' are + * skipped. If the file cannot be opened, nothing is loaded. + * @param cfg Config store; NULL is accepted and ignored. + * @param path Path of the file to load; NULL is accepted and ignored. + */ void cwist_config_load_file(cwist_config *cfg, const char *path) { if (!cfg || !path) return; FILE *f = fopen(path, "r"); diff --git a/src/sys/app/csrf.c b/src/sys/app/csrf.c index 233716e7a..478319353 100644 --- a/src/sys/app/csrf.c +++ b/src/sys/app/csrf.c @@ -12,10 +12,23 @@ #define CWIST_CSRF_TOKEN_BYTES 32 #define CWIST_CSRF_TOKEN_CHARS (CWIST_CSRF_TOKEN_BYTES * 2) +/** + * @brief Check whether an HTTP method is exempt from CSRF validation. + * @param method HTTP method to test. + * @return true for GET, HEAD, and OPTIONS; false otherwise. + */ static bool csrf_safe_method(cwist_http_method_t method) { return method == CWIST_HTTP_GET || method == CWIST_HTTP_HEAD || method == CWIST_HTTP_OPTIONS; } +/** + * @brief Generate a new random CSRF token. + * Reads 32 bytes from /dev/urandom and hex-encodes them into a + * 64-character NUL-terminated string. + * @return Newly allocated token string on success; NULL on I/O failure or + * allocation failure. Caller owns the returned buffer and must free + * it with cwist_free(). + */ static char *csrf_generate_token(void) { unsigned char bytes[CWIST_CSRF_TOKEN_BYTES]; int fd = open("/dev/urandom", O_RDONLY | O_CLOEXEC); @@ -43,6 +56,13 @@ static char *csrf_generate_token(void) { return token; } +/** + * @brief Compare two strings for equality in constant time. + * @param a First string; may be NULL. + * @param b Second string; may be NULL. + * @return true if both strings are non-NULL, equal in length and content; + * false otherwise. + */ static bool csrf_equal(const char *a, const char *b) { if (!a || !b) return false; size_t alen = strlen(a), blen = strlen(b); @@ -56,6 +76,11 @@ static bool csrf_equal(const char *a, const char *b) { return diff == 0; } +/** + * @brief Decode a single hexadecimal digit character to its numeric value. + * @param c Character to decode. + * @return Numeric value 0-15 on success; -1 if @p c is not a hex digit. + */ static int hex_value(unsigned char c) { if (c >= '0' && c <= '9') return c - '0'; if (c >= 'a' && c <= 'f') return c - 'a' + 10; @@ -63,6 +88,17 @@ static int hex_value(unsigned char c) { return -1; } +/** + * @brief Extract and URL-decode the CSRF token from a form-encoded body. + * Scans the request body for a "_csrf" or "csrf_token" field and decodes + * its value ('+' becomes a space, '%XX' becomes the byte). Stops at the + * first matching field. + * @param req Request whose body is parsed. + * @return Newly allocated decoded value on success; NULL if no matching + * field exists, on malformed percent-encoding, or on allocation + * failure. Caller owns the returned buffer and must free it with + * cwist_free(). + */ static char *csrf_form_value(const cwist_http_request *req) { if (!req || !req->body || !req->body->data) return NULL; const char *body = req->body->data; @@ -104,6 +140,15 @@ static char *csrf_form_value(const cwist_http_request *req) { return NULL; } +/** + * @brief Set the CSRF cookie on the response. + * Cookie is scoped to path "/", expires after 30 days, is marked Secure + * when the app uses SSL, and uses SameSite=Strict. Not HttpOnly so the + * client-side code can read it for form submission. + * @param req Current request (used to determine SSL usage). + * @param res Response to attach the cookie to. + * @param token Token value to store in the cookie. + */ static void csrf_issue_cookie(cwist_http_request *req, cwist_http_response *res, const char *token) { cwist_cookie_options options = {0}; @@ -115,6 +160,18 @@ static void csrf_issue_cookie(cwist_http_request *req, cwist_http_response *res, cwist_cookie_set(res, CWIST_CSRF_COOKIE_NAME, token, &options); } +/** + * @brief CSRF protection middleware handler. + * Ensures every request carries a CSRF token cookie, refreshing it when + * absent. For unsafe methods (not GET/HEAD/OPTIONS) it validates the token + * from the X-CSRF-Token header or the parsed form body against the cookie + * and rejects mismatches with 403 Forbidden. Stores the current token in + * req->csrf_token (freeing any previous value). Safe methods are passed + * through to @p next. + * @param req Current request; receives the token in req->csrf_token. + * @param res Response object; status and cookie may be modified. + * @param next Handler to invoke when the request passes validation. + */ static void csrf_handler(cwist_http_request *req, cwist_http_response *res, cwist_handler_func next) { const char *cookie_header = cwist_http_header_get(req->headers, "Cookie"); @@ -165,10 +222,21 @@ static void csrf_handler(cwist_http_request *req, cwist_http_response *res, next(req, res); } +/** + * @brief Create the CSRF protection middleware. + * @param app Application instance; unused. + * @return The CSRF middleware handler function. + */ cwist_middleware_func cwist_mw_csrf(cwist_app *app) { (void)app; return csrf_handler; } +/** + * @brief Get the CSRF token associated with a request. + * @param req Request to query. + * @return The request's CSRF token, or NULL if @p req is NULL or no token + * has been set. The returned pointer is owned by the request. + */ const char *cwist_csrf_token(cwist_http_request *req) { return req ? req->csrf_token : NULL; } diff --git a/src/sys/app/logger.c b/src/sys/app/logger.c index b17beccf6..33e2c580c 100644 --- a/src/sys/app/logger.c +++ b/src/sys/app/logger.c @@ -13,10 +13,20 @@ static const char *level_strings[] = {"DEBUG", "INFO", "WARN", "ERROR"}; static const char *level_colors[] = {"\033[36m", "\033[32m", "\033[33m", "\033[31m"}; static const char *color_reset = "\033[0m"; +/** @brief Returns the output stream for the given log level. + * @param level Log level. + * @return stderr for WARN and ERROR, stdout otherwise. + */ static FILE *level_file(cwist_log_level_t level) { return level >= CWIST_LOG_WARN ? stderr : stdout; } +/** @brief Creates a logger instance. + * @param name Logger name copied into the instance; NULL selects "cwist". + * @return Newly allocated logger on success, NULL on allocation failure. + * @note Ownership of the returned logger transfers to the caller; release it + * with cwist_logger_destroy(). + */ cwist_logger *cwist_logger_create(const char *name) { cwist_logger *log = (cwist_logger *)cwist_alloc(sizeof(cwist_logger)); if (!log) return NULL; @@ -26,20 +36,39 @@ cwist_logger *cwist_logger_create(const char *name) { return log; } +/** @brief Destroys a logger instance and frees its name. + * @param log Logger to free; NULL is safely ignored. + */ void cwist_logger_destroy(cwist_logger *log) { if (!log) return; cwist_free(log->name); cwist_free(log); } +/** @brief Sets the minimum log level that will be emitted. + * @param log Logger to configure; NULL is safely ignored. + * @param level New minimum level; messages below it are suppressed. + */ void cwist_logger_set_level(cwist_logger *log, cwist_log_level_t level) { if (log) log->level = level; } +/** @brief Enables or disables ANSI color output in log lines. + * @param log Logger to configure; NULL is safely ignored. + * @param enabled true to colorize level prefixes, false for plain output. + */ void cwist_logger_set_colors(cwist_logger *log, bool enabled) { if (log) log->use_colors = enabled; } +/** @brief Logs a formatted message at the given level. + * @param log Logger to log through; NULL is safely ignored. + * @param level Level of the message; messages below the logger's level are suppressed. + * @param fmt printf-style format string. + * @note The message is written to stdout for levels below WARN and to stderr + * otherwise, with a timestamp, level tag, and logger name prefix, followed by a newline. + * Not thread-safe; localtime() and the shared FILE streams are used. + */ void cwist_logger_log(cwist_logger *log, cwist_log_level_t level, const char *fmt, ...) { if (!log || level < log->level) return; FILE *out = level_file(level); @@ -61,6 +90,11 @@ void cwist_logger_log(cwist_logger *log, cwist_log_level_t level, const char *fm fflush(out); } +/** @brief Logs a formatted message at DEBUG level. + * @param log Logger to log through; NULL is safely ignored. + * @param fmt printf-style format string. + * @note Currently emits only an empty prefix line; see the note in the body. + */ void cwist_logger_debug(cwist_logger *log, const char *fmt, ...) { if (!log || CWIST_LOG_DEBUG < log->level) return; va_list args; @@ -72,6 +106,10 @@ void cwist_logger_debug(cwist_logger *log, const char *fmt, ...) { but here we just forward via the variadic macro pattern in header. */ } +/** @brief Logs a formatted message at INFO level on stdout. + * @param log Logger to log through; NULL is safely ignored. + * @param fmt printf-style format string. + */ void cwist_logger_info(cwist_logger *log, const char *fmt, ...) { if (!log || CWIST_LOG_INFO < log->level) return; va_list args; @@ -93,6 +131,10 @@ void cwist_logger_info(cwist_logger *log, const char *fmt, ...) { fflush(out); } +/** @brief Logs a formatted message at WARN level on stderr. + * @param log Logger to log through; NULL is safely ignored. + * @param fmt printf-style format string. + */ void cwist_logger_warn(cwist_logger *log, const char *fmt, ...) { if (!log || CWIST_LOG_WARN < log->level) return; va_list args; @@ -114,6 +156,10 @@ void cwist_logger_warn(cwist_logger *log, const char *fmt, ...) { fflush(out); } +/** @brief Logs a formatted message at ERROR level on stderr. + * @param log Logger to log through; NULL is safely ignored. + * @param fmt printf-style format string. + */ void cwist_logger_error(cwist_logger *log, const char *fmt, ...) { if (!log || CWIST_LOG_ERROR < log->level) return; va_list args; diff --git a/src/sys/app/middleware.c b/src/sys/app/middleware.c index 6eb909b92..92b3e1820 100644 --- a/src/sys/app/middleware.c +++ b/src/sys/app/middleware.c @@ -81,6 +81,13 @@ cwist_middleware_func cwist_mw_request_id(const char *header_name) { static pthread_mutex_t log_mutex = PTHREAD_MUTEX_INITIALIZER; +/** + * @brief Format a timestamp in Common Log Format: dd/Mon/yyyy:HH:MM:SS +zzzz + * in local time. + * @param buf Output buffer. + * @param len Size of @p buf in bytes. + * @param tv Timestamp to format. + */ static void format_clf_time(char *buf, size_t len, const struct timeval *tv) { struct tm tm; localtime_r(&tv->tv_sec, &tm); @@ -91,6 +98,13 @@ static void format_clf_time(char *buf, size_t len, const struct timeval *tv) { snprintf(buf + pos, len - pos, " %s", tzbuf); } +/** + * @brief Format a timestamp in UTC ISO 8601 with millisecond precision: + * yyyy-MM-ddTHH:MM:SS.mmmZ. + * @param buf Output buffer. + * @param len Size of @p buf in bytes. + * @param tv Timestamp to format. + */ static void format_iso8601_time(char *buf, size_t len, const struct timeval *tv) { struct tm tm; gmtime_r(&tv->tv_sec, &tm); @@ -99,16 +113,34 @@ static void format_iso8601_time(char *buf, size_t len, const struct timeval *tv) snprintf(buf + pos, len - pos, ".%03ldZ", tv->tv_usec / 1000); } -/* After next() a deferred response belongs to its cwist_async completion, +/** + * @brief Resolve the request ID to log, preferring the response headers. + * + * After next() a deferred response belongs to its cwist_async completion, * which may be writing it on another thread right now (cwist_async_respond(), * the timeout job). The access logs then do not read it: status and size are - * logged as unknown (@p unknown) and the request id comes from the request, - * where cwist_mw_request_id() also puts it. */ + * logged as unknown and the request id comes from the request, + * where cwist_mw_request_id() also puts it. + * + * @param req Incoming HTTP request. + * @param res Outgoing HTTP response. + * @return Header value of X-Request-Id, or NULL if absent. + */ static const char *access_log_request_id(cwist_http_request *req, cwist_http_response *res) { cwist_http_header_node *headers = res->deferred ? req->headers : res->headers; return cwist_http_header_get(headers, "X-Request-Id"); } +/** + * @brief Render response status code and body size as strings for access logs. + * @param res Outgoing HTTP response. + * @param unknown Placeholder written to both outputs when @p res is deferred + * and its status/size cannot be read safely. + * @param status Output buffer for the status code. + * @param status_len Size of @p status in bytes. + * @param bytes Output buffer for the response body size. + * @param bytes_len Size of @p bytes in bytes. + */ static void access_log_status_bytes(const cwist_http_response *res, const char *unknown, char *status, size_t status_len, char *bytes, size_t bytes_len) { @@ -121,6 +153,13 @@ static void access_log_status_bytes(const cwist_http_response *res, const char * snprintf(bytes, bytes_len, "%zu", res->body ? res->body->size : (size_t)0); } +/** + * @brief Common Log Format access-log middleware; prints one line per request + * to stdout after the handler completes. + * @param req Incoming HTTP request. + * @param res Outgoing HTTP response. + * @param next Next middleware or final handler in the chain. + */ static void cwist_mw_access_log_common_handler(cwist_http_request *req, cwist_http_response *res, cwist_handler_func next) { struct timeval start, end; @@ -147,6 +186,13 @@ static void cwist_mw_access_log_common_handler(cwist_http_request *req, cwist_ht if (ip) cwist_sstring_destroy(ip); } +/** + * @brief Combined Log Format access-log middleware; like the common format but + * also logs Referer and User-Agent, one line per request to stdout. + * @param req Incoming HTTP request. + * @param res Outgoing HTTP response. + * @param next Next middleware or final handler in the chain. + */ static void cwist_mw_access_log_combined_handler(cwist_http_request *req, cwist_http_response *res, cwist_handler_func next) { struct timeval start, end; @@ -176,6 +222,13 @@ static void cwist_mw_access_log_combined_handler(cwist_http_request *req, cwist_ if (ip) cwist_sstring_destroy(ip); } +/** + * @brief JSON access-log middleware; prints one JSON object per request to + * stdout, including status, duration, and request/response byte counts. + * @param req Incoming HTTP request. + * @param res Outgoing HTTP response. + * @param next Next middleware or final handler in the chain. + */ static void cwist_mw_access_log_json_handler(cwist_http_request *req, cwist_http_response *res, cwist_handler_func next) { struct timeval start, end; @@ -267,9 +320,19 @@ static ip_bucket_t ip_buckets[MAX_IP_BUCKETS]; static int ip_bucket_count = 0; static pthread_mutex_t rate_mutex = PTHREAD_MUTEX_INITIALIZER; -/* The rate is passed in, not through req->private_data: inside the app's +/** + * @brief Per-IP token-bucket rate-limit handler; rejects with 429 when the + * client exceeds the allowed requests per minute. + * + * The rate is passed in, not through req->private_data: inside the app's * middleware chain that field holds the chain's own state, which next() - * reads. */ + * reads. + * + * @param req Incoming HTTP request. + * @param res Outgoing HTTP response. + * @param next Next middleware or final handler in the chain. + * @param rpm Allowed requests per minute (token bucket refill rate). + */ static void cwist_mw_rate_limit_ip_handler(cwist_http_request *req, cwist_http_response *res, cwist_handler_func next, int rpm) { if (rpm <= 0) rpm = 60; @@ -353,9 +416,7 @@ static cwist_middleware_func s_rate_wrappers[CWIST_RATE_LIMIT_MAX_CFGS] = { }; /** - * @brief Return the built-in per-IP rate-limiter middleware. - * @param requests_per_minute Allowed requests per minute (token bucket rate). - * @return Middleware function pointer for per-IP rate limiting. + * @brief Reset all per-IP rate-limiter buckets, clearing tracked clients. */ void cwist_mw_rate_limit_reset(void) { pthread_mutex_lock(&rate_mutex); @@ -363,6 +424,17 @@ void cwist_mw_rate_limit_reset(void) { pthread_mutex_unlock(&rate_mutex); } +/** + * @brief Return the built-in per-IP rate-limiter middleware. + * + * Reuses an existing wrapper when the same rate was already registered. If + * all CWIST_RATE_LIMIT_MAX_CFGS config slots are taken, logs a warning and + * falls back to the first registered wrapper. + * + * @param requests_per_minute Allowed requests per minute (token bucket rate); + * values <= 0 fall back to 60. + * @return Middleware function pointer for per-IP rate limiting. + */ cwist_middleware_func cwist_mw_rate_limit_ip(int requests_per_minute) { if (requests_per_minute <= 0) requests_per_minute = 60; diff --git a/src/sys/app/shutdown.c b/src/sys/app/shutdown.c index 69a8b1061..af452972c 100644 --- a/src/sys/app/shutdown.c +++ b/src/sys/app/shutdown.c @@ -22,6 +22,14 @@ int g_cwist_udp_fd = -1; * longer sits out the full timeout on every shutdown. */ int g_cwist_drain_timeout_sec = 5; +/** + * @brief Constructor that initializes the drain timeout from the environment. + * + * Runs automatically before main(). If CWIST_DRAIN_TIMEOUT is set to a value + * between 0 and 3600 (inclusive) it overrides g_cwist_drain_timeout_sec; + * missing, empty, or malformed values leave the default in place, and an + * invalid value is reported on stderr. + */ static void __attribute__((constructor)) cwist_drain_timeout_init(void) { const char *env = getenv("CWIST_DRAIN_TIMEOUT"); if (!env || !*env) return; @@ -35,6 +43,16 @@ static void __attribute__((constructor)) cwist_drain_timeout_init(void) { } } +/** + * @brief Request a graceful shutdown of the server. + * + * Clears the g_cwist_running flag and closes the listening and UDP + * descriptors (if open). Closing the listen descriptor also calls + * shutdown(SHUT_RDWR) so accept() loops blocked in other threads wake up + * with EINVAL. Safe to call more than once and from any thread: each + * descriptor is claimed atomically, so a second call cannot close a + * descriptor number that has been reused for an unrelated file. + */ void cwist_shutdown_request(void) { atomic_store(&g_cwist_running, 0); /* Take each descriptor atomically so a second request (another thread, @@ -57,6 +75,10 @@ void cwist_shutdown_request(void) { } #ifndef __wasi__ +/** + * @brief Signal handler for SIGTERM/SIGINT; forwards to cwist_shutdown_request(). + * @param sig Signal number (ignored). + */ static void cwist_shutdown_handler(int sig) { (void)sig; cwist_shutdown_request(); @@ -72,6 +94,16 @@ static struct sigaction g_prev_sigterm; static struct sigaction g_prev_sigint; static bool g_prev_saved = false; +/** + * @brief Install CWIST's SIGTERM/SIGINT handlers. + * + * Both signals are routed to cwist_shutdown_handler() with SA_RESTART. The + * pre-existing handlers are saved on the first install after a restore so + * cwist_shutdown_restore_handlers() can put them back; a second install + * without a restore does not overwrite the saved handlers. The previous + * handlers are only recorded when both sigaction() calls succeed. No-op on + * WASI hosts, which own the instance lifecycle. + */ void cwist_shutdown_install_handlers(void) { struct sigaction sa, old_term, old_int; sigemptyset(&sa.sa_mask); @@ -86,6 +118,14 @@ void cwist_shutdown_install_handlers(void) { } } +/** + * @brief Restore the SIGTERM/SIGINT handlers saved by the first + * cwist_shutdown_install_handlers() call. + * + * Does nothing if no previous handlers were saved. After restoring, the saved + * state is cleared so a later install records the handlers then in effect. + * No-op on WASI hosts. + */ void cwist_shutdown_restore_handlers(void) { if (!g_prev_saved) return; sigaction(SIGTERM, &g_prev_sigterm, NULL); @@ -93,14 +133,39 @@ void cwist_shutdown_restore_handlers(void) { g_prev_saved = false; } #else +/** + * @brief Install CWIST's SIGTERM/SIGINT handlers. + * + * Both signals are routed to cwist_shutdown_handler() with SA_RESTART. The + * pre-existing handlers are saved on the first install after a restore so + * cwist_shutdown_restore_handlers() can put them back; a second install + * without a restore does not overwrite the saved handlers. The previous + * handlers are only recorded when both sigaction() calls succeed. No-op on + * WASI hosts, which own the instance lifecycle. + */ void cwist_shutdown_install_handlers(void) { /* WASI hosts own the instance lifecycle; there are no signals to * install. Shutdown is driven by the host dropping the context. */ } +/** + * @brief Restore the SIGTERM/SIGINT handlers saved by the first + * cwist_shutdown_install_handlers() call. + * + * Does nothing if no previous handlers were saved. After restoring, the saved + * state is cleared so a later install records the handlers then in effect. + * No-op on WASI hosts. + */ void cwist_shutdown_restore_handlers(void) {} #endif +/** + * @brief Reset shutdown state so the process can serve again. + * + * Sets g_cwist_running back to 1 and marks the listening and UDP descriptors + * as closed (-1). Does not close any open descriptors or touch signal + * handlers. + */ void cwist_shutdown_reset(void) { atomic_store(&g_cwist_running, 1); g_cwist_listen_fd = -1; diff --git a/src/sys/app/test_client.c b/src/sys/app/test_client.c index c103e8ef1..7c6153fd8 100644 --- a/src/sys/app/test_client.c +++ b/src/sys/app/test_client.c @@ -10,6 +10,10 @@ #include #include +/** @brief Duplicate a C string using cwist_alloc. + * @param s Source string; may be NULL. + * @return Newly allocated copy owned by the caller, or NULL if @p s is NULL or allocation fails. + */ static char *clone_str(const char *s) { if (!s) return NULL; size_t n = strlen(s) + 1; @@ -18,6 +22,9 @@ static char *clone_str(const char *s) { return p; } +/** @brief Release a cookie and all strings it owns. + * @param c Cookie to free; NULL is a no-op. + */ static void free_cookie(cwist_test_client_cookie *c) { if (!c) return; cwist_free(c->name); @@ -26,6 +33,11 @@ static void free_cookie(cwist_test_client_cookie *c) { cwist_free(c); } +/** @brief Look up a cookie by name in a singly linked list. + * @param head First node of the list. + * @param name Cookie name to match; NULL matches nothing. + * @return Matching node, or NULL if not found. + */ static cwist_test_client_cookie *find_cookie(cwist_test_client_cookie *head, const char *name) { if (!name) return NULL; for (; head; head = head->next) { @@ -34,6 +46,13 @@ static cwist_test_client_cookie *find_cookie(cwist_test_client_cookie *head, con return NULL; } +/** @brief Build a Cookie header from the jar and ad-hoc pairs and add it to a request. + * Does nothing if @p client or @p req is NULL, or if there are no valid cookies to send. + * @param client Client owning the cookie jar. + * @param req Request to receive the "Cookie" header. + * @param adhoc Ad-hoc key/value pairs; may be NULL. + * @param adhoc_count Number of entries in @p adhoc; NULL or empty-valued entries are skipped. + */ static void cookie_jar_apply(cwist_test_client *client, cwist_http_request *req, const cwist_test_client_kv *adhoc, size_t adhoc_count) { if (!client || !req) return; @@ -77,6 +96,10 @@ static void cookie_jar_apply(cwist_test_client *client, cwist_http_request *req, cwist_http_header_add(&req->headers, "Cookie", cookie_header); } +/** @brief Strip leading and trailing whitespace from a string in place. + * @param s Mutable null-terminated string. + * @return Pointer to the first non-whitespace character (within @p s). + */ static char *trim(char *s) { while (*s && isspace((unsigned char)*s)) s++; char *e = s + strlen(s); @@ -84,6 +107,12 @@ static char *trim(char *s) { return s; } +/** @brief Update the cookie jar from Set-Cookie headers in a response. + * Adds new cookies to the head of the list; existing cookie names get their value + * (and path, if present) replaced. Malformed Set-Cookie headers are ignored. + * @param client Client owning the cookie jar. + * @param res Response whose headers are scanned; NULL is a no-op. + */ static void cookie_jar_update(cwist_test_client *client, cwist_http_response *res) { if (!client || !res) return; for (cwist_http_header_node *h = res->headers; h; h = h->next) { @@ -150,6 +179,11 @@ static void cookie_jar_update(cwist_test_client *client, cwist_http_response *re } } +/** @brief Create a test HTTP client bound to an app. + * @param app App to dispatch requests to; must not be NULL. + * @return Newly allocated client with an empty cookie jar, or NULL on invalid input or + * allocation failure. Free with cwist_test_client_destroy(). + */ cwist_test_client *cwist_test_client_create(cwist_app *app) { if (!app) return NULL; cwist_test_client *client = (cwist_test_client *)cwist_alloc(sizeof(cwist_test_client)); @@ -159,12 +193,26 @@ cwist_test_client *cwist_test_client_create(cwist_app *app) { return client; } +/** @brief Destroy a test client and free all its cookies. + * @param client Client to destroy; NULL is a no-op. + */ void cwist_test_client_destroy(cwist_test_client *client) { if (!client) return; cwist_test_client_clear_cookies(client); cwist_free(client); } +/** @brief Build and dispatch an HTTP request through the client. + * Splits any query string off @p path, applies options (body, content type, headers, + * ad-hoc cookies), adds jar cookies, dispatches to the app, and updates the cookie jar + * from the response. The request object is destroyed before returning. + * @param client Client to send through. + * @param method HTTP method to use. + * @param path Request path, optionally containing a "?query" suffix; must not be NULL. + * @param opts Optional request options; NULL means no body, headers, or ad-hoc cookies. + * @return Newly allocated response owned by the caller (free with + * cwist_http_response_destroy()), or NULL on invalid input or allocation failure. + */ static cwist_http_response *do_request(cwist_test_client *client, cwist_http_method_t method, const char *path, const cwist_test_client_request_options *opts) { @@ -228,16 +276,34 @@ static cwist_http_response *do_request(cwist_test_client *client, cwist_http_met return res; } +/** @brief Send a request with explicit method and options. + * @param client Client to send through. + * @param method HTTP method to use. + * @param path Request path, optionally containing a "?query" suffix. + * @param opts Optional request options (body, headers, content type, ad-hoc cookies). + * @return Response owned by the caller, or NULL on failure. See do_request(). + */ cwist_http_response *cwist_test_client_request_ex(cwist_test_client *client, cwist_http_method_t method, const char *path, const cwist_test_client_request_options *opts) { return do_request(client, method, path, opts); } +/** @brief Send a GET request. + * @param client Client to send through. + * @param path Request path, optionally containing a "?query" suffix. + * @return Response owned by the caller, or NULL on failure. See do_request(). + */ cwist_http_response *cwist_test_client_get(cwist_test_client *client, const char *path) { return do_request(client, CWIST_HTTP_GET, path, NULL); } +/** @brief Send a POST request with a plain-text body. + * @param client Client to send through. + * @param path Request path. + * @param body Null-terminated request body; may be NULL. + * @return Response owned by the caller, or NULL on failure. See do_request(). + */ cwist_http_response *cwist_test_client_post(cwist_test_client *client, const char *path, const char *body) { cwist_test_client_request_options opts = {0}; @@ -245,6 +311,12 @@ cwist_http_response *cwist_test_client_post(cwist_test_client *client, const cha return do_request(client, CWIST_HTTP_POST, path, &opts); } +/** @brief Send a POST request with a JSON body and application/json content type. + * @param client Client to send through. + * @param path Request path. + * @param json_body Null-terminated JSON body; may be NULL. + * @return Response owned by the caller, or NULL on failure. See do_request(). + */ cwist_http_response *cwist_test_client_post_json(cwist_test_client *client, const char *path, const char *json_body) { cwist_test_client_request_options opts = {0}; @@ -253,6 +325,12 @@ cwist_http_response *cwist_test_client_post_json(cwist_test_client *client, cons return do_request(client, CWIST_HTTP_POST, path, &opts); } +/** @brief Send a PUT request with a plain-text body. + * @param client Client to send through. + * @param path Request path. + * @param body Null-terminated request body; may be NULL. + * @return Response owned by the caller, or NULL on failure. See do_request(). + */ cwist_http_response *cwist_test_client_put(cwist_test_client *client, const char *path, const char *body) { cwist_test_client_request_options opts = {0}; @@ -260,10 +338,21 @@ cwist_http_response *cwist_test_client_put(cwist_test_client *client, const char return do_request(client, CWIST_HTTP_PUT, path, &opts); } +/** @brief Send a DELETE request. + * @param client Client to send through. + * @param path Request path. + * @return Response owned by the caller, or NULL on failure. See do_request(). + */ cwist_http_response *cwist_test_client_delete(cwist_test_client *client, const char *path) { return do_request(client, CWIST_HTTP_DELETE, path, NULL); } +/** @brief Send a PATCH request with a plain-text body. + * @param client Client to send through. + * @param path Request path. + * @param body Null-terminated request body; may be NULL. + * @return Response owned by the caller, or NULL on failure. See do_request(). + */ cwist_http_response *cwist_test_client_patch(cwist_test_client *client, const char *path, const char *body) { cwist_test_client_request_options opts = {0}; @@ -271,6 +360,18 @@ cwist_http_response *cwist_test_client_patch(cwist_test_client *client, const ch return do_request(client, CWIST_HTTP_PATCH, path, &opts); } +/** @brief Send a POST request with a single-file multipart/form-data body. + * Builds the multipart body with a fixed boundary, including Content-Disposition and + * Content-Type headers for the file part. The body buffer is freed before returning. + * @param client Client to send through. + * @param path Request path. + * @param field_name Form field name for the file part; must not be NULL. + * @param file_name File name for the Content-Disposition header; must not be NULL. + * @param content_type MIME type of the file data; must not be NULL. + * @param data Raw file bytes; must not be NULL. + * @param data_len Length of @p data in bytes. + * @return Response owned by the caller, or NULL on invalid input or allocation failure. + */ cwist_http_response *cwist_test_client_post_multipart(cwist_test_client *client, const char *path, const char *field_name, const char *file_name, const char *content_type, const char *data, @@ -309,6 +410,15 @@ cwist_http_response *cwist_test_client_post_multipart(cwist_test_client *client, return res; } +/** @brief Set or replace a cookie in the client's jar. + * A cookie with the same name gets its value replaced (and path, if @p path is given); + * otherwise a new cookie is prepended to the jar. Strings are copied. + * @param client Client owning the jar; NULL is a no-op. + * @param name Cookie name; must not be NULL. + * @param value Cookie value; NULL clears the stored value. + * @param path Cookie path; NULL leaves the path unchanged for existing cookies or stores + * no path for new ones. + */ void cwist_test_client_set_cookie(cwist_test_client *client, const char *name, const char *value, const char *path) { if (!client || !name) return; @@ -331,12 +441,21 @@ void cwist_test_client_set_cookie(cwist_test_client *client, const char *name, c } } +/** @brief Get the value of a cookie from the client's jar. + * @param client Client owning the jar; NULL yields NULL. + * @param name Cookie name to look up. + * @return Cookie value, or NULL if not found or on invalid input. The returned pointer + * is owned by the client and must not be freed. + */ const char *cwist_test_client_get_cookie(cwist_test_client *client, const char *name) { if (!client || !name) return NULL; cwist_test_client_cookie *c = find_cookie(client->cookies, name); return c ? c->value : NULL; } +/** @brief Remove and free all cookies in the client's jar. + * @param client Client owning the jar; NULL is a no-op. + */ void cwist_test_client_clear_cookies(cwist_test_client *client) { if (!client) return; cwist_test_client_cookie *c = client->cookies; diff --git a/src/sys/app/waf.c b/src/sys/app/waf.c index db5d87680..c1f73d694 100644 --- a/src/sys/app/waf.c +++ b/src/sys/app/waf.c @@ -7,6 +7,9 @@ #include #include +/** @brief Lowercase an ASCII letter, leaving all other bytes unchanged. + * @param c Input byte. + * @return The lowercase equivalent of @p c for 'A'..'Z', otherwise @p c. */ static unsigned char ascii_lower(unsigned char c) { return c >= 'A' && c <= 'Z' ? (unsigned char)(c + ('a' - 'A')) : c; } @@ -46,6 +49,16 @@ static bool waf_out[WAF_MAX_TEXT]; static uint16_t waf_states; static pthread_once_t waf_once = PTHREAD_ONCE_INIT; +/** @brief Build the Aho-Corasick automaton from the signature table. + * + * Runs once via pthread_once. Inserts every signature into the goto table, + * then computes BFS failure links and folds them into the goto table so the + * scan loop is a single table index per byte. Not thread-safe by itself; + * callers must synchronize via pthread_once. + * + * Fails open: if the state space exceeds 256 states the build is abandoned + * and waf_states is left incomplete, in which case cwist_waf_is_safe() + * simply never reports a match for deeper patterns. */ static void waf_build(void) { uint16_t queue[WAF_MAX_TEXT]; waf_states = 1; /* state 0: root */ @@ -86,6 +99,17 @@ static void waf_build(void) { } } +/** @brief Check whether a byte string is free of hostile signatures. + * + * Scans @p input once (O(length)) through the case-insensitive Aho-Corasick + * automaton, which is built lazily on first call. Matching is ASCII-only: + * bytes at or above 0x80 reset the automaton state, and control bytes other + * than tab, newline, and carriage return are rejected outright. + * + * @param input Byte string to scan; may be NULL. + * @param length Number of bytes to scan. + * @return true if the input is safe, false if a signature or disallowed + * control byte is found. NULL input is considered safe. */ bool cwist_waf_is_safe(const char *input, size_t length) { if (!input) return true; pthread_once(&waf_once, waf_build); @@ -105,6 +129,15 @@ bool cwist_waf_is_safe(const char *input, size_t length) { return true; } +/** @brief Escape HTML-special characters in a NUL-terminated string. + * + * Replaces '&', '<', '>', '"', and '\'' with their HTML entity equivalents + * and returns a newly allocated buffer holding the escaped text. + * + * @param input Input string; may be NULL. + * @return Newly allocated, NUL-terminated escaped string that the caller + * owns and must free with cwist_free, or NULL if @p input is NULL, + * on allocation failure, or on size overflow. */ char *cwist_sanitize_html(const char *input) { if (!input) return NULL; size_t length = strlen(input), extra = 0; @@ -143,6 +176,10 @@ char *cwist_sanitize_html(const char *input) { return output; } +/** @brief Check every header key/value pair in a linked list for hostile + * signatures. + * @param header First node of the header list; may be NULL (treated as safe). + * @return true if all present keys and values pass cwist_waf_is_safe(). */ static bool waf_headers_safe(const cwist_http_header_node *header) { for (; header; header = header->next) { if ((header->key && !cwist_waf_is_safe(header->key->data, header->key->size)) || @@ -152,6 +189,15 @@ static bool waf_headers_safe(const cwist_http_header_node *header) { return true; } +/** @brief Middleware handler that screens a request with the WAF checks. + * + * Runs cwist_waf_is_safe() over the request path, query, body, and headers. + * If any part fails, the response is set to HTTP 400 with a fixed body and + * the next handler is not invoked; otherwise the request is passed through. + * + * @param req Request to screen; NULL is treated as safe for its fields. + * @param res Response to fill when the request is rejected. + * @param next Next handler in the chain, invoked when the request passes. */ static void waf_handler(cwist_http_request *req, cwist_http_response *res, cwist_handler_func next) { bool safe = req && (!req->path || cwist_waf_is_safe(req->path->data, req->path->size)) && @@ -166,6 +212,9 @@ static void waf_handler(cwist_http_request *req, cwist_http_response *res, next(req, res); } +/** @brief Construct the WAF-lite middleware. + * @return A middleware function that screens each request with the + * signature-based WAF checks before invoking the next handler. */ cwist_middleware_func cwist_mw_waf_lite(void) { return waf_handler; } diff --git a/src/sys/app/worker_affinity.h b/src/sys/app/worker_affinity.h index a2668e003..c8889df16 100644 --- a/src/sys/app/worker_affinity.h +++ b/src/sys/app/worker_affinity.h @@ -6,6 +6,19 @@ #include #include +/** + * @brief Pin the calling thread to a single CPU chosen from its allowed set. + * + * Reads the calling thread's current CPU affinity mask, selects the + * (worker_index % number-of-allowed-CPUs)-th CPU in ascending order, and + * restricts the affinity mask to that CPU. Requires GNU affinity APIs + * (_GNU_SOURCE) for sched_getaffinity/sched_setaffinity and the CPU_* macros. + * + * @param worker_index Ordinal of the worker; mapped onto the allowed CPU set + * with modulo arithmetic so any value is accepted. + * @return 0 on success, -1 on failure with errno set (EINVAL if the allowed + * set is empty or the selected CPU cannot be found/applied). + */ static inline int cwist_app_pin_worker(size_t worker_index) { cpu_set_t allowed; if (sched_getaffinity(0, sizeof(allowed), &allowed) < 0) return -1; diff --git a/src/sys/err/error.c b/src/sys/err/error.c index f107a4a06..ced304b0b 100644 --- a/src/sys/err/error.c +++ b/src/sys/err/error.c @@ -18,11 +18,28 @@ cwist_error_t make_error(cwist_errtype_t type) { return err; } -/* Forwards to the inline helper so the two can never disagree. */ +/** + * @brief Out-of-line variant of the inline cwist_error_is_ok() helper. + * + * Forwards to the inline helper so the two can never disagree. Success means + * the active channel holds its zero/empty value; NULL counts as success and + * an unknown errtype counts as failure. + * @param err Error object to test (may be NULL). + * @return true when @p err represents success. + */ bool cwist_error_is_ok_extern(const cwist_error_t *err) { return cwist_error_is_ok(err); } +/** + * @brief Release resources owned by an error value. + * + * Currently only CWIST_ERR_JSON owns memory (the cJSON payload); all other + * variants are no-ops. Safe on success values and on NULL. The released + * cJSON pointer is reset to NULL, but the rest of the error value is left + * as-is. + * @param err Error object to release (may be NULL). + */ void cwist_error_dispose(cwist_error_t *err) { if (!err) return; if (err->errtype == CWIST_ERR_JSON && err->error.err_json) { diff --git a/src/sys/health/healthz.c b/src/sys/health/healthz.c index c83b3d234..97d60c9d0 100644 --- a/src/sys/health/healthz.c +++ b/src/sys/health/healthz.c @@ -30,6 +30,18 @@ static int g_entry_count = 0; * Registration * ---------------------------------------------------------------------- */ +/** + * @brief Register or update a health probe. + * + * If a probe with the same name is already active, its function and context + * are replaced in place. Otherwise the probe is appended to the registry. + * + * @param name Probe name; must be non-NULL and outlive the registration. + * @param fn Probe function; must be non-NULL. + * @param ctx Opaque context passed to @p fn on each evaluation. + * @return true on success, false if @p name or @p fn is NULL or the registry + * is full. + */ bool cwist_healthz_register(const char *name, cwist_health_probe_fn fn, void *ctx) { if (!name || !fn) return false; @@ -64,6 +76,15 @@ bool cwist_healthz_register(const char *name, cwist_health_probe_fn fn, void *ct return true; } +/** + * @brief Deactivate a registered health probe. + * + * Marks every active entry with the given name as inactive; inactive entries + * are skipped by @ref cwist_healthz_run and their slots may be reused by + * @ref cwist_healthz_register. The name and function pointers are not cleared. + * + * @param name Probe name to remove; NULL is a no-op. + */ void cwist_healthz_unregister(const char *name) { if (!name) return; for (int i = 0; i < g_entry_count; ++i) { @@ -77,6 +98,12 @@ void cwist_healthz_unregister(const char *name) { * Evaluation * ---------------------------------------------------------------------- */ +/** + * @brief Map a health status enum to its JSON string representation. + * + * @param s Health status value. + * @return "ok", "degraded", "fail", or "unknown" for unrecognized values. + */ static const char *status_str(cwist_health_status_t s) { switch (s) { case CWIST_HEALTH_OK: return "ok"; @@ -86,6 +113,20 @@ static const char *status_str(cwist_health_status_t s) { } } +/** + * @brief Run all active probes and aggregate their results. + * + * Probes are evaluated in registration order. The overall status is FAIL if + * any probe reports FAIL, DEGRADED if at least one reports DEGRADED and none + * report FAIL, and OK otherwise. + * + * @param out_probes Optional array receiving per-probe results; at most + * @p max_probes entries are written. + * @param max_probes Capacity of @p out_probes. + * @param out_count Optional output receiving the number of active probes + * evaluated. + * @param out_overall Optional output receiving the aggregated status. + */ void cwist_healthz_run(cwist_health_probe_t *out_probes, size_t max_probes, size_t *out_count, cwist_health_status_t *out_overall) { size_t count = 0; @@ -114,6 +155,16 @@ void cwist_healthz_run(cwist_health_probe_t *out_probes, size_t max_probes, size * HTTP response helper * ---------------------------------------------------------------------- */ +/** + * @brief Serve the /healthz HTTP endpoint as a JSON response. + * + * Runs all registered probes and writes a JSON body with an overall "status" + * field and a "probes" array of per-probe name, status, and optional message. + * The response Content-Type is set to application/json. + * + * @param res Response to fill; NULL is a no-op. The body and headers are + * (re)assigned on success. + */ void cwist_app_healthz(cwist_http_response *res) { if (!res) return; diff --git a/src/sys/io/io_queue.c b/src/sys/io/io_queue.c index 49f3f6b2e..c87cf38ca 100644 --- a/src/sys/io/io_queue.c +++ b/src/sys/io/io_queue.c @@ -85,6 +85,11 @@ static void cwist_queue_defer_free(cwist_io_queue *q, job_node_t *node) { pthread_mutex_unlock(&q->retire_lock); } +/** + * @brief Epoch-retire callback that frees a retired queue node. + * Called by ttak once no thread can hold a reference to the node. + * @param ptr Node pointer staged via cwist_queue_defer_free(). + */ static void cwist_job_node_free_cb(void *ptr) { cwist_free(ptr); } diff --git a/src/sys/io/reactor.c b/src/sys/io/reactor.c index 013f2b995..eef78c535 100644 --- a/src/sys/io/reactor.c +++ b/src/sys/io/reactor.c @@ -74,13 +74,32 @@ struct io_uring_getevents_arg { struct __kernel_timespec; +/** @brief Raw io_uring_setup(2) syscall wrapper (no libc wrapper exists). + * @param entries Ring depth requested. + * @param p Out params/offsets filled by the kernel. + * @return Ring file descriptor on success, -1 with errno set on failure. */ static inline int sys_io_uring_setup(unsigned entries, struct io_uring_params *p) { return (int)syscall(__NR_io_uring_setup, entries, p); } +/** @brief Raw io_uring_enter(2) syscall wrapper. + * @param ring_fd Ring file descriptor. + * @param to_submit Number of queued SQEs to submit. + * @param min_complete Minimum completions to wait for (with GETEVENTS). + * @param flags IORING_ENTER_* flags. + * @param sig Signal mask to apply during the wait, or NULL. + * @return 0 or number of completions on success, -1 with errno set on failure. */ static inline int sys_io_uring_enter(int ring_fd, unsigned to_submit, unsigned min_complete, unsigned flags, sigset_t *sig) { return (int)syscall(__NR_io_uring_enter, ring_fd, to_submit, min_complete, flags, sig); } +/** @brief io_uring_enter with an absolute timeout, via IORING_ENTER_EXT_ARG. + * @param ring_fd Ring file descriptor. + * @param to_submit Number of queued SQEs to submit. + * @param min_complete Minimum completions to wait for. + * @param flags IORING_ENTER_* flags (EXT_ARG is OR-ed in here). + * @param ts Absolute timeout (CLOCK_MONOTONIC) as __kernel_timespec, or NULL. + * @return Completions/submit result on success, -1 with errno set (ETIME on + * timeout expiry) on failure. */ static inline int sys_io_uring_enter_timeout(int ring_fd, unsigned to_submit, unsigned min_complete, unsigned flags, const struct __kernel_timespec *ts) { struct io_uring_getevents_arg arg = {.ts = (uint64_t)(uintptr_t)ts}; @@ -88,20 +107,32 @@ static inline int sys_io_uring_enter_timeout(int ring_fd, unsigned to_submit, un flags | IORING_ENTER_EXT_ARG, &arg, sizeof(arg)); } -/* Ring setup helpers absorbed from the retired io_uring_backend.c. */ +/** @brief Map one io_uring ring region (SQ ring, CQ ring, or SQE array). + * + * No MAP_POPULATE: with one ring per worker thread the pre-faulted pages + * dominate idle RSS (~400 KiB per reactor) while a worker under real + * load only ever touches the head of each ring. On-demand paging keeps + * RSS proportional to actual concurrency. + * + * @param fd Ring file descriptor. + * @param sz Region size in bytes. + * @param off Region selector (IORING_OFF_*). + * @return Mapped pointer, or NULL on failure (caller munmaps and falls back). */ static void *mmap_ring(int fd, size_t sz, off_t off) { - /* No MAP_POPULATE: with one ring per worker thread the pre-faulted pages - * dominate idle RSS (~400 KiB per reactor) while a worker under real - * load only ever touches the head of each ring. On-demand paging keeps - * RSS proportional to actual concurrency. */ void *p = mmap(NULL, sz, PROT_READ | PROT_WRITE, MAP_SHARED, fd, off); return (p == MAP_FAILED) ? NULL : p; } +/** @brief Byte size of the mmap-ed SQ ring region for the given setup params. + * @param p Filled io_uring_params from io_uring_setup. + * @return Region size: sq_off.array offset plus the SQ index array. */ static size_t sq_ring_size(struct io_uring_params *p) { return p->sq_off.array + p->sq_entries * sizeof(uint32_t); } +/** @brief Byte size of the mmap-ed CQ ring region for the given setup params. + * @param p Filled io_uring_params from io_uring_setup. + * @return Region size: cq_off.cqes offset plus the CQE array. */ static size_t cq_ring_size(struct io_uring_params *p) { return p->cq_off.cqes + p->cq_entries * sizeof(struct io_uring_cqe); } @@ -226,9 +257,11 @@ static const uint32_t latency_probe_bounds_us[LATENCY_PROBE_BUCKETS - 1] = { 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000, 25000, 50000, 100000, 250000, 500000, 1000000, 2500000}; -/* CWIST_LATENCY_PROBE=1 enables the per-request latency probe. Cached after - * the first read like the other env knobs in this file -- the racy recompute - * is benign (same result every time). */ +/** @brief Whether the per-request latency probe is enabled (CWIST_LATENCY_PROBE=1). + * + * Cached after the first read like the other env knobs in this file -- the + * racy recompute is benign (same result every time). + * @return true when the probe is enabled. */ static bool latency_probe_enabled(void) { static _Atomic int cached = -1; int v = atomic_load_explicit(&cached, memory_order_relaxed); @@ -240,6 +273,16 @@ static bool latency_probe_enabled(void) { return v == 1; } +/** @brief Record one latency sample (microseconds) into a probe histogram. + * + * Binary-searches the bucket whose upper bound first exceeds the sample, + * then updates the bucket, count, running sum, max, and over-5ms counters. + * @param buckets Histogram bucket array (LATENCY_PROBE_BUCKETS entries). + * @param count In/out total sample count. + * @param sum_us In/out running sum of samples. + * @param max_us In/out running maximum. + * @param over_5ms In/out count of samples beyond 5000 us. + * @param sample_us The sample value in microseconds. */ static void latency_probe_record(uint64_t *buckets, uint64_t *count, uint64_t *sum_us, uint64_t *max_us, uint64_t *over_5ms, uint64_t sample_us) { int lo = 0, hi = LATENCY_PROBE_BUCKETS - 1; @@ -259,8 +302,13 @@ static void latency_probe_record(uint64_t *buckets, uint64_t *count, uint64_t *s enum { LATENCY_PROBE_QUEUE = 0, LATENCY_PROBE_SVC = 1 }; -/* Approximate percentile from a histogram: smallest bucket upper bound whose - * cumulative count reaches pct (in per-mille) of total. Returns micros. */ +/** @brief Approximate percentile from a histogram: smallest bucket upper + * bound whose cumulative count reaches pct (in per-mille) of total. + * @param buckets Histogram bucket array. + * @param count Total sample count. + * @param pct_mille Percentile in per-mille (500 = p50, 999 = p99.9). + * @return Bucket upper bound in microseconds, or UINT64_MAX for the + * open-ended top bucket. */ static uint64_t latency_probe_percentile(const uint64_t *buckets, uint64_t count, uint64_t pct_mille) { uint64_t target = (count * pct_mille + 999) / 1000; diff --git a/src/sys/io/uring_sqpoll.c b/src/sys/io/uring_sqpoll.c index f64996c5f..ed9a77ce1 100644 --- a/src/sys/io/uring_sqpoll.c +++ b/src/sys/io/uring_sqpoll.c @@ -9,10 +9,30 @@ #include #include +/** + * @brief Invoke the io_uring_setup syscall. + * + * @param entries Number of submission queue entries to request. + * @param params io_uring_params filled in by the kernel on success. + * @return Ring file descriptor on success, or -1 with errno set by the syscall. + */ static inline int sys_io_uring_setup(unsigned entries, struct io_uring_params *p) { return (int)syscall(__NR_io_uring_setup, entries, p); } +/** + * @brief Initialize an SQPOLL io_uring ring for submission-only send operations. + * + * Creates the ring with IORING_SETUP_SQPOLL so a kernel thread polls the + * submission queue, and mmaps the SQ ring and SQE array as shared memory. + * + * @param r Ring state to initialize; zeroed before use. + * @param entries Requested submission queue size (must be non-zero). + * @param sq_thread_idle_ms Idle timeout for the kernel SQ thread; 0 selects + * the default of 2000 ms. + * @return 0 on success, -1 on failure (invalid arguments, or the ring file + * descriptor or either mmap failed; all resources are released). + */ int cwist_io_uring_init_sqpoll(cwist_sqpoll_ring_t *r, unsigned entries, unsigned sq_thread_idle_ms) { if (!r || entries == 0) return -1; @@ -60,6 +80,20 @@ int cwist_io_uring_init_sqpoll(cwist_sqpoll_ring_t *r, unsigned entries, return 0; } +/** + * @brief Queue a SEND operation on the SQPOLL submission ring. + * + * Not thread-safe: the caller must serialize submissions. Non-blocking: if + * the submission queue is full, no SQE is posted. + * + * @param r Active ring initialized by cwist_io_uring_init_sqpoll(). + * @param fd Socket file descriptor to send on. + * @param buf Buffer to send; must remain valid until the operation completes. + * @param len Number of bytes to send. + * @param user_data Value carried through to the completion for this operation. + * @retval true The SQE was posted to the submission queue. + * @retval false The ring is NULL or inactive, or the submission queue is full. + */ bool cwist_io_uring_sqpoll_send(cwist_sqpoll_ring_t *r, int fd, const void *buf, size_t len, uint64_t user_data) { if (!r || !r->active) return false; @@ -87,6 +121,15 @@ bool cwist_io_uring_sqpoll_send(cwist_sqpoll_ring_t *r, int fd, const void *buf, return true; } +/** + * @brief Tear down an SQPOLL ring and release its resources. + * + * Unmaps the SQE array and SQ ring, closes the ring file descriptor, and + * zeroes the ring state so a subsequent destroy is a no-op. Safe to call on + * a NULL or already-destroyed ring. + * + * @param r Ring to destroy. + */ void cwist_io_uring_destroy_sqpoll(cwist_sqpoll_ring_t *r) { if (!r || !r->active) return; if (r->sqes && r->sqes_sz) { diff --git a/src/sys/job/durable_queue.c b/src/sys/job/durable_queue.c index 153ec7029..0425c637e 100644 --- a/src/sys/job/durable_queue.c +++ b/src/sys/job/durable_queue.c @@ -124,12 +124,14 @@ struct cwist_job { uint32_t attempts; }; +/** @brief Wrap a raw int16 status code into a cwist_error_t (CWIST_ERR_INT16 carrier). */ static cwist_error_t jq_err(int16_t code) { cwist_error_t e = make_error(CWIST_ERR_INT16); e.error.err_i16 = code; return e; } +/** @brief Current CLOCK_MONOTONIC time in milliseconds; monotonic and unaffected by wall-clock changes. */ static uint64_t jq_now_ms(void) { struct timespec ts; clock_gettime(CLOCK_MONOTONIC, &ts); @@ -138,6 +140,7 @@ static uint64_t jq_now_ms(void) { static unsigned int jq_consumer_seq = 0; +/** @brief Build an owned default consumer name "w-"; unique per process run. */ static char *jq_default_consumer(void) { char buf[64]; snprintf(buf, sizeof(buf), "w%d-%u", (int)getpid(), ++jq_consumer_seq); @@ -158,6 +161,11 @@ static bool jq_opts_init(cwist_job_queue_t *q, const cwist_job_queue_opts_t *opt return true; } +/** + * @brief Free a job and all its owned resources. + * @param job Job to release (NULL is a no-op). Also destroys the borrowed + * backend natsMsg, if any. + */ static void cwist_job_free(cwist_job_t *job) { if (!job) return; if (job->backend) natsMsg_Destroy((natsMsg *)job->backend); @@ -167,10 +175,12 @@ static void cwist_job_free(cwist_job_t *job) { cwist_free(job); } +/** @brief Public alias of cwist_job_free(); releases a job returned by claim. */ void cwist_job_destroy(cwist_job_t *job) { cwist_job_free(job); } +/** @brief Return the job payload pointer and store its length in @a len (0 on NULL job). */ const void *cwist_job_payload(const cwist_job_t *job, size_t *len) { if (!job) { if (len) *len = 0; @@ -180,20 +190,24 @@ const void *cwist_job_payload(const cwist_job_t *job, size_t *len) { return job->payload; } +/** @brief Return the job type string, or NULL if none / NULL job. */ const char *cwist_job_type(const cwist_job_t *job) { return job ? job->type : NULL; } +/** @brief Return the backend entry id of the job, or NULL for a NULL job. */ const char *cwist_job_id(const cwist_job_t *job) { return job ? job->id : NULL; } +/** @brief Return the delivery attempt count of the job (0 for a NULL job). */ uint32_t cwist_job_attempts(const cwist_job_t *job) { return job ? job->attempts : 0; } /* --- Redis backend ------------------------------------------------------ */ +/** @brief Allocate an owned Redis key "cwist:jq::"; NULL on ENOMEM. */ static char *jq_key(const char *name, const char *suffix) { size_t n = strlen(name) + strlen(suffix) + 16; char *key = cwist_alloc(n); @@ -201,6 +215,11 @@ static char *jq_key(const char *name, const char *suffix) { return key; } +/** + * @brief Run an argv-style Redis command and keep only the reply. + * @retval err 0 on success with *reply set; CWIST_FAILURE otherwise, with + * *reply freed and NULLed. + */ static cwist_error_t jq_redis_cmd(cwist_redis_t *conn, size_t argc, const void *const *argv, const size_t *argv_lens, cwist_redis_reply_t **reply) { cwist_error_t err = cwist_redis_command_argv_reply(conn, argc, argv, argv_lens, reply); @@ -247,6 +266,10 @@ static char *jq_blob(const char *type, uint32_t attempts, const void *payload, s return blob; } +/** + * @brief Redis backend: append a job to the stream (XADD with type/payload fields). + * @param type Optional job type (NULL stored as empty); payload may be binary. + */ static cwist_error_t jq_redis_enqueue(cwist_job_queue_t *q, const void *payload, size_t payload_len, const char *type) { jq_redis_t *rs = &q->u.redis; @@ -271,6 +294,10 @@ static const cwist_redis_reply_t *jq_entry_field(const cwist_redis_reply_t *fiel return NULL; } +/** + * @brief Build an owned cwist_job_t from a [id, fields] stream entry reply. + * @return New job, or NULL on malformed entry or allocation failure. + */ static cwist_job_t *jq_job_from_entry(cwist_job_queue_t *q, const cwist_redis_reply_t *entry) { if (!entry || entry->type != '*' || entry->elements != 2 || !entry->element[0] || !entry->element[0]->str) @@ -430,6 +457,7 @@ static cwist_job_t *jq_redis_readgroup(cwist_job_queue_t *q, uint64_t block_ms) return job; } +/** @brief Move delayed blobs whose due time passed back onto the stream (Lua, fire-and-forget). */ static void jq_redis_reap_delay(cwist_job_queue_t *q) { jq_redis_t *rs = &q->u.redis; char now[32]; @@ -446,6 +474,12 @@ static void jq_redis_reap_delay(cwist_job_queue_t *q) { jq_redis_run(rs->conn, 7, argv, lens); } +/** + * @brief Redis backend: claim one job within timeout_ms. + * @return 0 with *job set on success; CWIST_ERROR_TIMEOUT when the deadline + * passes with no job. Polls reaped delayed jobs, then stale pending + * entries, then blocks for new entries. + */ static cwist_error_t jq_redis_claim(cwist_job_queue_t *q, uint64_t timeout_ms, cwist_job_t **job) { uint64_t deadline = jq_now_ms() + timeout_ms; bool polled = false; @@ -470,6 +504,7 @@ static cwist_error_t jq_redis_claim(cwist_job_queue_t *q, uint64_t timeout_ms, c } } +/** @brief Redis backend: acknowledge a job (XACK) and drop its attempts hash entry. */ static cwist_error_t jq_redis_ack(cwist_job_queue_t *q, cwist_job_t *job) { jq_redis_t *rs = &q->u.redis; const void *argv[] = {"XACK", rs->stream, CWIST_JQ_GROUP, job->id}; @@ -483,6 +518,12 @@ static cwist_error_t jq_redis_ack(cwist_job_queue_t *q, cwist_job_t *job) { return err; } +/** + * @brief Redis backend: negatively acknowledge a job via the JQ_NACK_SCRIPT + * Lua script, parking it in the delay zset or dead-lettering it once + * max_retries is exceeded. + * @retval CWIST_ERROR_NOMEM if the delay blob cannot be allocated. + */ static cwist_error_t jq_redis_nack(cwist_job_queue_t *q, cwist_job_t *job) { jq_redis_t *rs = &q->u.redis; uint32_t next = job->attempts + 1; @@ -518,6 +559,7 @@ static cwist_error_t jq_redis_nack(cwist_job_queue_t *q, cwist_job_t *job) { return err; } +/** @brief Redis backend: store the length of the dead-letter list in *out_count. */ static cwist_error_t jq_redis_dead_count(cwist_job_queue_t *q, uint64_t *out_count) { jq_redis_t *rs = &q->u.redis; const void *argv[] = {"LLEN", rs->dead}; @@ -528,6 +570,12 @@ static cwist_error_t jq_redis_dead_count(cwist_job_queue_t *q, uint64_t *out_cou return err; } +/** + * @brief Create a durable job queue backed by Redis streams. + * @param conn Borrowed Redis connection (must outlive the queue). + * @return New queue with consumer group ensured, or NULL on invalid opts, + * ENOMEM, or Redis error. Frees nothing on @a conn. + */ cwist_job_queue_t *cwist_job_queue_create_redis(cwist_redis_t *conn, const cwist_job_queue_opts_t *opts) { if (!conn) return NULL; @@ -586,6 +634,10 @@ static char *jq_sanitize(const char *name) { return out; } +/** + * @brief Create the JetStream stream if it does not already exist (file storage). + * @retval 0 on success (already present or created); CWIST_FAILURE otherwise. + */ static cwist_error_t jq_nats_ensure_stream(jq_nats_t *ns, const char *stream, const char *subject) { jsErrCode jerr = 0; jsStreamInfo *si = NULL; @@ -605,6 +657,12 @@ static cwist_error_t jq_nats_ensure_stream(jq_nats_t *ns, const char *stream, co return jq_err(0); } +/** + * @brief Create a durable job queue backed by NATS JetStream. + * @param nats Borrowed cwist_nats wrapper; its native connection must outlive the queue. + * @return New queue with streams and durable pull consumer ensured, or NULL + * on invalid opts, ENOMEM, or NATS error. + */ cwist_job_queue_t *cwist_job_queue_create_nats(cwist_nats_t *nats, const cwist_job_queue_opts_t *opts) { if (!nats) return NULL; @@ -697,6 +755,10 @@ cwist_job_queue_t *cwist_job_queue_create_nats(cwist_nats_t *nats, return NULL; } +/** + * @brief NATS backend: publish a job message with an optional "Cwist-Type" header. + * @retval CWIST_FAILURE on message creation or publish failure. + */ static cwist_error_t jq_nats_enqueue(cwist_job_queue_t *q, const void *payload, size_t payload_len, const char *type) { jq_nats_t *ns = &q->u.nats; @@ -731,6 +793,12 @@ static void jq_nats_dead_letter(jq_nats_t *ns, natsMsg *msg) { natsMsg_Term(msg, NULL); } +/** + * @brief NATS backend: fetch one job within timeout_ms via the pull consumer. + * @return 0 with *job set; CWIST_ERROR_TIMEOUT on deadline expiry, + * CWIST_FAILURE on fetch error, CWIST_ERROR_NOMEM on allocation + * failure. Exhausted deliveries are dead-lettered and skipped. + */ static cwist_error_t jq_nats_claim(cwist_job_queue_t *q, uint64_t timeout_ms, cwist_job_t **job) { jq_nats_t *ns = &q->u.nats; uint64_t deadline = jq_now_ms() + timeout_ms; @@ -803,6 +871,7 @@ static cwist_error_t jq_nats_claim(cwist_job_queue_t *q, uint64_t timeout_ms, cw } } +/** @brief NATS backend: ack the message and take over its destruction (job->backend is NULLed). */ static cwist_error_t jq_nats_ack(cwist_job_queue_t *q, cwist_job_t *job) { (void)q; natsStatus s = natsMsg_Ack((natsMsg *)job->backend, NULL); @@ -811,6 +880,7 @@ static cwist_error_t jq_nats_ack(cwist_job_queue_t *q, cwist_job_t *job) { return s == NATS_OK ? jq_err(0) : jq_err(CWIST_FAILURE); } +/** @brief NATS backend: nak the message (with retry delay if configured) and destroy it; job->backend is NULLed. */ static cwist_error_t jq_nats_nack(cwist_job_queue_t *q, cwist_job_t *job) { natsStatus s; if (q->retry_delay_ms) @@ -822,6 +892,7 @@ static cwist_error_t jq_nats_nack(cwist_job_queue_t *q, cwist_job_t *job) { return s == NATS_OK ? jq_err(0) : jq_err(CWIST_FAILURE); } +/** @brief NATS backend: store the message count of the dead-letter stream in *out_count. */ static cwist_error_t jq_nats_dead_count(cwist_job_queue_t *q, uint64_t *out_count) { jq_nats_t *ns = &q->u.nats; jsErrCode jerr = 0; @@ -835,6 +906,7 @@ static cwist_error_t jq_nats_dead_count(cwist_job_queue_t *q, uint64_t *out_coun /* --- Dispatch ----------------------------------------------------------- */ +/** @brief Destroy a queue and all backend resources it owns; NULL is a no-op. In-flight jobs are not touched. */ void cwist_job_queue_destroy(cwist_job_queue_t *q) { if (!q) return; if (q->backend == 0) { @@ -855,6 +927,10 @@ void cwist_job_queue_destroy(cwist_job_queue_t *q) { cwist_free(q); } +/** + * @brief Enqueue a job payload with an optional type. + * @retval CWIST_ERROR_INVALID_PARAM on NULL queue or NULL payload with nonzero length. + */ cwist_error_t cwist_job_queue_enqueue(cwist_job_queue_t *q, const void *payload, size_t payload_len, const char *type) { if (!q || (!payload && payload_len)) return jq_err(CWIST_ERROR_INVALID_PARAM); @@ -863,12 +939,20 @@ cwist_error_t cwist_job_queue_enqueue(cwist_job_queue_t *q, const void *payload, : jq_nats_enqueue(q, payload, payload_len, type); } +/** + * @brief Claim one job, waiting up to timeout_ms. + * @return 0 with *job set (NULL on parameter error); backend errors propagate. + */ cwist_error_t cwist_job_queue_claim(cwist_job_queue_t *q, uint64_t timeout_ms, cwist_job_t **job) { if (!q || !job) return jq_err(CWIST_ERROR_INVALID_PARAM); *job = NULL; return q->backend == 0 ? jq_redis_claim(q, timeout_ms, job) : jq_nats_claim(q, timeout_ms, job); } +/** + * @brief Acknowledge a claimed job and free it. + * @retval CWIST_ERROR_INVALID_PARAM if the job does not belong to @a q. + */ cwist_error_t cwist_job_queue_ack(cwist_job_queue_t *q, cwist_job_t *job) { if (!q || !job || job->q != q) return jq_err(CWIST_ERROR_INVALID_PARAM); cwist_error_t err = q->backend == 0 ? jq_redis_ack(q, job) : jq_nats_ack(q, job); @@ -876,6 +960,10 @@ cwist_error_t cwist_job_queue_ack(cwist_job_queue_t *q, cwist_job_t *job) { return err; } +/** + * @brief Negatively acknowledge a claimed job (retry or dead-letter) and free it. + * @retval CWIST_ERROR_INVALID_PARAM if the job does not belong to @a q. + */ cwist_error_t cwist_job_queue_nack(cwist_job_queue_t *q, cwist_job_t *job) { if (!q || !job || job->q != q) return jq_err(CWIST_ERROR_INVALID_PARAM); cwist_error_t err = q->backend == 0 ? jq_redis_nack(q, job) : jq_nats_nack(q, job); @@ -883,6 +971,11 @@ cwist_error_t cwist_job_queue_nack(cwist_job_queue_t *q, cwist_job_t *job) { return err; } +/** + * @brief Store the number of dead-lettered jobs in *out_count. + * @retval CWIST_ERROR_INVALID_PARAM on NULL arguments; CWIST_FAILURE if the + * backend has no dead-count implementation. + */ cwist_error_t cwist_job_queue_dead_count(cwist_job_queue_t *q, uint64_t *out_count) { if (!q || !out_count) return jq_err(CWIST_ERROR_INVALID_PARAM); if (!q->dead_count) return jq_err(CWIST_FAILURE); diff --git a/src/sys/job/scheduler.c b/src/sys/job/scheduler.c index b6bdb2446..b5e0e2a58 100644 --- a/src/sys/job/scheduler.c +++ b/src/sys/job/scheduler.c @@ -42,17 +42,40 @@ struct cwist_scheduler { /* --- Time helpers ------------------------------------------------------- */ +/** + * @brief Return the current monotonic clock time in milliseconds. + * + * Uses CLOCK_MONOTONIC, so the value is not affected by wall-clock changes. + * + * @retval Millisecond timestamp on success. + * @retval 0 if clock_gettime() fails. + */ static uint64_t now_ms(void) { struct timespec ts; if (clock_gettime(CLOCK_MONOTONIC, &ts) != 0) return 0; return (uint64_t)ts.tv_sec * 1000ULL + (uint64_t)ts.tv_nsec / 1000000ULL; } +/** + * @brief Convert a millisecond timestamp into a struct timespec. + * + * @param[in] ms Milliseconds since the monotonic clock epoch. + * @param[out] ts Destination timespec to fill. + */ static void ms_to_timespec(uint64_t ms, struct timespec *ts) { ts->tv_sec = (time_t)(ms / 1000ULL); ts->tv_nsec = (long)((ms % 1000ULL) * 1000000ULL); } +/** + * @brief Initialize a condition variable using the monotonic clock. + * + * Where supported (Linux and the BSDs) the condition variable is configured + * with CLOCK_MONOTONIC so timed waits are immune to wall-clock changes; + * elsewhere the platform default clock is used. + * + * @param[out] cond Condition variable to initialize. + */ static void cond_init_monotonic(pthread_cond_t *cond) { pthread_condattr_t attr; pthread_condattr_init(&attr); @@ -65,6 +88,17 @@ static void cond_init_monotonic(pthread_cond_t *cond) { /* --- Min-heap of delayed jobs ------------------------------------------- */ +/** + * @brief Grow the delayed-job heap if it is full. + * + * Doubles the heap capacity, starting from CWIST_SCHEDULER_HEAP_INIT. The new + * buffer is immediately disowned from the calling thread's GC tracking (when + * full GC is enabled) because the scheduler owns it until + * cwist_scheduler_destroy(). + * + * @retval 0 on success or if capacity is already sufficient. + * @retval -1 if the allocation failed. + */ static int heap_grow(cwist_scheduler_t *s) { if (s->heap_count < s->heap_capacity) return 0; size_t new_cap = s->heap_capacity ? s->heap_capacity * 2 : CWIST_SCHEDULER_HEAP_INIT; @@ -83,12 +117,24 @@ static int heap_grow(cwist_scheduler_t *s) { return 0; } +/** + * @brief Swap two heap entries. + */ static void heap_swap(scheduled_job_t *a, scheduled_job_t *b) { scheduled_job_t tmp = *a; *a = *b; *b = tmp; } +/** + * @brief Push a job onto the min-heap ordered by deadline. + * + * Grows the heap first if needed. On allocation failure the job is silently + * dropped. Caller must hold @p s->delayed_mtx. + * + * @param s Scheduler owning the heap. + * @param job Job entry to insert. + */ static void heap_push(cwist_scheduler_t *s, scheduled_job_t job) { if (heap_grow(s) != 0) return; size_t i = s->heap_count++; @@ -101,6 +147,13 @@ static void heap_push(cwist_scheduler_t *s, scheduled_job_t job) { } } +/** + * @brief Pop the earliest-deadline job from the min-heap. + * + * Caller must hold @p s->delayed_mtx and the heap must be non-empty. + * + * @return The job with the smallest deadline_ms. + */ static scheduled_job_t heap_pop(cwist_scheduler_t *s) { scheduled_job_t root = s->heap[0]; s->heap[0] = s->heap[--s->heap_count]; @@ -124,12 +177,26 @@ static scheduled_job_t heap_pop(cwist_scheduler_t *s) { /* --- Worker and timer threads ------------------------------------------- */ +/** + * @brief Worker thread entry point. + * + * Runs the scheduler's io queue event loop; returns NULL when the queue is + * stopped. + */ static void *scheduler_worker_thread(void *arg) { cwist_scheduler_t *s = (cwist_scheduler_t *)arg; cwist_io_queue_run(s->queue); return NULL; } +/** + * @brief Timer thread entry point. + * + * Waits on @p s->delayed_cond for delayed jobs. When the earliest deadline + * arrives, the job is submitted to the io queue for execution by a worker; + * otherwise it sleeps until the deadline with a timed wait. Exits when + * @p s->running becomes false. + */ static void *scheduler_timer_thread(void *arg) { cwist_scheduler_t *s = (cwist_scheduler_t *)arg; @@ -158,6 +225,16 @@ static void *scheduler_timer_thread(void *arg) { return NULL; } +/** + * @brief Stop and join the first @p started_workers worker threads. + * + * Used on the partial-failure paths of cwist_scheduler_create(). Stops the io + * queue first so workers can exit their event loops. + * + * @param s Scheduler being torn down. + * @param started_workers Number of worker threads that were successfully + * started and must be joined. + */ static void scheduler_cleanup_threads(cwist_scheduler_t *s, size_t started_workers) { s->running = false; cwist_io_queue_stop(s->queue); @@ -168,6 +245,17 @@ static void scheduler_cleanup_threads(cwist_scheduler_t *s, size_t started_worke /* --- Public API --------------------------------------------------------- */ +/** + * @brief Create a scheduler with worker and timer threads. + * + * Spawns @p worker_count worker threads (defaulting to 1 if 0) running the io + * queue event loop, plus one timer thread for delayed jobs. On any failure, + * all partially created resources are torn down and freed. + * + * @param worker_count Number of worker threads; 0 is treated as 1. + * @param queue_capacity Capacity passed to cwist_io_queue_create(). + * @return The new scheduler, or NULL on allocation or thread-creation failure. + */ cwist_scheduler_t *cwist_scheduler_create(size_t worker_count, size_t queue_capacity) { if (worker_count == 0) worker_count = 1; @@ -218,6 +306,15 @@ cwist_scheduler_t *cwist_scheduler_create(size_t worker_count, size_t queue_capa return s; } +/** + * @brief Destroy a scheduler and free all its resources. + * + * Stops the timer and worker threads (waiting for them to finish), then + * destroys the io queue and frees the heap, worker array, and scheduler. + * Safe to call with NULL. + * + * @param s Scheduler to destroy. + */ void cwist_scheduler_destroy(cwist_scheduler_t *s) { if (!s) return; @@ -241,11 +338,36 @@ void cwist_scheduler_destroy(cwist_scheduler_t *s) { cwist_free(s); } +/** + * @brief Submit a job for immediate execution by a worker thread. + * + * @param s Scheduler to submit to. + * @param func Job function to run. + * @param arg Opaque argument passed to @p func. + * @retval true if the job was submitted. + * @retval false if @p s or @p func is NULL, the scheduler is not running, or + * the queue submission failed. + */ bool cwist_scheduler_submit(cwist_scheduler_t *s, cwist_job_func func, void *arg) { if (!s || !func || !s->running) return false; return cwist_io_queue_submit(s->queue, func, arg); } +/** + * @brief Schedule a job to run after a delay. + * + * With a zero delay the job is submitted immediately as with + * cwist_scheduler_submit(). Otherwise it is pushed onto the delayed-job + * min-heap and the timer thread runs it once the deadline passes. + * + * @param s Scheduler to schedule on. + * @param func Job function to run. + * @param arg Opaque argument passed to @p func. + * @param delay_ms Delay in milliseconds before execution; 0 means immediate. + * @retval true if the job was scheduled. + * @retval false if @p s or @p func is NULL, the scheduler is not running, or + * heap insertion failed (e.g. out of memory). + */ bool cwist_scheduler_schedule(cwist_scheduler_t *s, cwist_job_func func, void *arg, uint64_t delay_ms) { if (!s || !func || !s->running) return false; @@ -270,6 +392,14 @@ bool cwist_scheduler_schedule(cwist_scheduler_t *s, cwist_job_func func, void *a return true; } +/** + * @brief Return the number of jobs waiting on the delayed-job heap. + * + * Does not include jobs already submitted to the io queue. + * + * @param s Scheduler to query. + * @return Number of pending delayed jobs; 0 if @p s is NULL. + */ size_t cwist_scheduler_pending_count(cwist_scheduler_t *s) { if (!s) return 0; pthread_mutex_lock(&s->delayed_mtx); diff --git a/src/sys/metrics/metrics.c b/src/sys/metrics/metrics.c index 8cc81a388..3b1c89ab6 100644 --- a/src/sys/metrics/metrics.c +++ b/src/sys/metrics/metrics.c @@ -100,6 +100,15 @@ static const cwist_metric_t metric_defaults[CWIST_METRIC_COUNT] = { * Singleton registry * ---------------------------------------------------------------------- */ +/** + * @brief Return the process-wide singleton metrics registry. + * + * Allocates and initializes the registry on first call; later calls return + * the cached pointer. All metric atomics are zero-initialized from the + * static metric_defaults table. + * + * @return Pointer to the singleton registry, or NULL on allocation failure. + */ cwist_metrics_registry_t *cwist_metrics_registry(void) { static cwist_metrics_registry_t *g_reg = NULL; if (__builtin_expect(g_reg != NULL, 1)) return g_reg; @@ -116,6 +125,11 @@ cwist_metrics_registry_t *cwist_metrics_registry(void) { return g_reg; } +/** + * @brief Reset all metrics in a registry to zero. + * + * @param reg Registry to reset; NULL is a no-op. + */ void cwist_metrics_reset(cwist_metrics_registry_t *reg) { if (!reg) return; for (int i = 0; i < CWIST_METRIC_COUNT; ++i) { @@ -154,24 +168,63 @@ static void cwist_metrics_sync_tls(cwist_metrics_registry_t *reg) { } } +/** + * @brief Increment a metric's value by one and bump its observation count. + * + * Lock-free relaxed atomic update; safe to call from any thread. + * + * @param reg Registry holding the metric; NULL is a no-op. + * @param id Metric identifier; out-of-range ids are ignored. + */ void cwist_metric_inc(cwist_metrics_registry_t *reg, cwist_metric_id_t id) { if (!reg || id < 0 || id >= CWIST_METRIC_COUNT) return; atomic_fetch_add_explicit(®->metrics[id].value.raw, 1, memory_order_relaxed); atomic_fetch_add_explicit(®->metrics[id].count, 1, memory_order_relaxed); } +/** + * @brief Add a delta to a metric's value and bump its observation count. + * + * Lock-free relaxed atomic update; safe to call from any thread. + * + * @param reg Registry holding the metric; NULL is a no-op. + * @param id Metric identifier; out-of-range ids are ignored. + * @param delta Value increment to add. + */ void cwist_metric_add(cwist_metrics_registry_t *reg, cwist_metric_id_t id, uintmax_t delta) { if (!reg || id < 0 || id >= CWIST_METRIC_COUNT) return; atomic_fetch_add_explicit(®->metrics[id].value.raw, delta, memory_order_relaxed); atomic_fetch_add_explicit(®->metrics[id].count, 1, memory_order_relaxed); } +/** + * @brief Set a gauge metric to an absolute value. + * + * The value is stored with fixed-point scaling: the raw value is + * roundl(value * 1000), so one unit is 0.001 in rendered output. + * + * @param reg Registry holding the metric; NULL is a no-op. + * @param id Metric identifier; out-of-range ids are ignored. + * @param value New absolute value. + */ void cwist_metric_set(cwist_metrics_registry_t *reg, cwist_metric_id_t id, long double value) { if (!reg || id < 0 || id >= CWIST_METRIC_COUNT) return; uintmax_t scaled = (uintmax_t)roundl(value * 1000.0L); atomic_store_explicit(®->metrics[id].value.raw, scaled, memory_order_relaxed); } +/** + * @brief Observe a sample for a metric: accumulate sum and bump the count. + * + * The value is stored with fixed-point scaling (roundl(value * 1000)), + * added to both the raw accumulated value and the sum, while the + * observation count is incremented by one. Lock-free relaxed atomic + * update; safe to call from any thread. + * + * @param reg Registry holding the metric; NULL is a no-op. + * @param id Metric identifier; out-of-range ids are ignored. + * @param value Observed sample value. + */ void cwist_metric_observe(cwist_metrics_registry_t *reg, cwist_metric_id_t id, long double value) { if (!reg || id < 0 || id >= CWIST_METRIC_COUNT) return; uintmax_t scaled = (uintmax_t)roundl(value * 1000.0L); @@ -180,6 +233,17 @@ void cwist_metric_observe(cwist_metrics_registry_t *reg, cwist_metric_id_t id, l atomic_fetch_add_explicit(®->metrics[id].sum, scaled, memory_order_relaxed); } +/** + * @brief Read the current raw value of a metric. + * + * Acquire load of the stored raw (fixed-point scaled) value. For + * CWIST_METRIC_HTTP_CONTINUATION_SHED the value is read live from the + * net/http shed counter instead of the registry. + * + * @param reg Registry holding the metric; NULL returns 0. + * @param id Metric identifier; out-of-range ids return 0. + * @return Current raw metric value. + */ uintmax_t cwist_metric_load(const cwist_metrics_registry_t *reg, cwist_metric_id_t id) { if (!reg || id < 0 || id >= CWIST_METRIC_COUNT) return 0; cwist_metrics_sync_tls((cwist_metrics_registry_t *)reg); @@ -193,6 +257,11 @@ uintmax_t cwist_metric_load(const cwist_metrics_registry_t *reg, cwist_metric_id * Prometheus exposition * ---------------------------------------------------------------------- */ +/** + * @brief Map a metric type enum to its Prometheus exposition type string. + * @param t Metric type. + * @return Static string "counter", "gauge", "histogram", or "unknown". + */ static const char *type_str(cwist_metric_type_t t) { switch (t) { case CWIST_METRIC_COUNTER: return "counter"; @@ -202,6 +271,18 @@ static const char *type_str(cwist_metric_type_t t) { } } +/** + * @brief Render the full registry in Prometheus text exposition format. + * + * Before rendering, synchronizes the HTTP_CONTINUATION_SHED metric from + * the live net/http shed counter into the registry (cast away const for + * that store). The output buffer grows by doubling and is heap-allocated; + * the caller owns it. + * + * @param reg Registry to render; NULL returns NULL. + * @return Newly allocated NUL-terminated exposition text, or NULL on + * allocation or formatting failure. + */ char *cwist_metrics_render_prometheus(const cwist_metrics_registry_t *reg) { if (!reg) return NULL; @@ -272,6 +353,16 @@ char *cwist_metrics_render_prometheus(const cwist_metrics_registry_t *reg) { return buf; } +/** + * @brief HTTP handler that serves the metrics registry as Prometheus text. + * + * On success sets the response status to 200 OK with the exposition text + * as body and a Prometheus text Content-Type header. On render failure + * sets 500 INTERNAL_ERROR with a "metrics render failed" body. + * + * @param res Response to fill; NULL is a no-op. The body sstring is + * assigned via cwist_sstring_assign. + */ void cwist_metrics_serve_http(cwist_http_response *res) { if (!res) return; cwist_metrics_registry_t *reg = cwist_metrics_registry(); diff --git a/src/sys/session/flash.c b/src/sys/session/flash.c index 0e99848fc..e43c650a3 100644 --- a/src/sys/session/flash.c +++ b/src/sys/session/flash.c @@ -8,6 +8,15 @@ #include +/** @brief Store a flash entry on the request. + * + * Creates the request's flash query map on first use. A NULL value is + * stored as an empty string. + * + * @param req Request to attach the flash entry to; ignored if NULL. + * @param key Entry key; ignored if NULL. + * @param value Entry value; NULL is stored as "". + */ void cwist_flash_set(cwist_http_request *req, const char *key, const char *value) { if (!req || !key) return; if (!req->flash) { @@ -18,11 +27,31 @@ void cwist_flash_set(cwist_http_request *req, const char *key, const char *value } } +/** @brief Read a flash entry without removing it. + * + * Non-destructive lookup; the entry remains in the flash map. + * + * @param req Request whose flash map is read. + * @param key Entry key to look up. + * @return Pointer to the stored value, or NULL if req/key are NULL or the + * key is not present. The pointer stays owned by the flash map. + */ const char *cwist_flash_peek(cwist_http_request *req, const char *key) { if (!req || !key || !req->flash) return NULL; return cwist_query_map_get(req->flash, key); } +/** @brief Read and remove a flash entry. + * + * Destructive lookup: on success the entry's bucket is unlinked and freed, + * and ownership of the value string is transferred to the caller, who must + * release it with cwist_free(). + * + * @param req Request whose flash map is read. + * @param key Entry key to look up. + * @return Newly owned copy pointer to the value (caller frees), or NULL if + * req/key are NULL, the flash map is empty, or the key is missing. + */ const char *cwist_flash_get(cwist_http_request *req, const char *key) { if (!req || !key || !req->flash || !req->flash->buckets || req->flash->size == 0) return NULL; uint64_t hash = siphash24(key, strlen(key), req->flash->seed); @@ -45,6 +74,16 @@ const char *cwist_flash_get(cwist_http_request *req, const char *key) { return NULL; } +/** @brief Serialize all flash entries to a JSON object and clear the map. + * + * Builds a JSON object string {"key":"value",...} with '"' and '\' escaped + * in keys and values, then frees every flash entry and empties the map. + * + * @param req Request whose flash map is serialized. + * @return Newly allocated NUL-terminated JSON string the caller must free + * with cwist_free(), or NULL if there are no entries or if a buffer + * allocation fails (in which case the flash map is left unchanged). + */ char *cwist_flash_pop_all_json(cwist_http_request *req) { if (!req || !req->flash || !req->flash->buckets || req->flash->size == 0) return NULL; int count = 0; diff --git a/src/sys/wasi/compat.c b/src/sys/wasi/compat.c index 9e512944a..66d094c02 100644 --- a/src/sys/wasi/compat.c +++ b/src/sys/wasi/compat.c @@ -30,10 +30,23 @@ /* --- Reactor: no event loop exists outside the process runtime. --------- */ +/** + * @brief Create a reactor instance (WASI stub). + * @return Always NULL; no event loop exists outside the process runtime. + */ cwist_reactor_t *cwist_reactor_create(void) { return NULL; } +/** + * @brief Modify an fd registration (WASI stub). + * @param reactor Ignored. + * @param fd Ignored. + * @param cb Ignored. + * @param payload Ignored. + * @param payload_size Ignored. + * @return Always false. + */ bool cwist_reactor_mod(cwist_reactor_t *reactor, int fd, cwist_reactor_cb_t cb, const void *payload, size_t payload_size) { (void)reactor; @@ -44,16 +57,35 @@ bool cwist_reactor_mod(cwist_reactor_t *reactor, int fd, cwist_reactor_cb_t cb, return false; } +/** + * @brief Remove an fd registration (WASI stub). + * @param reactor Ignored. + * @param fd Ignored. + * @return Always false. + */ bool cwist_reactor_del(cwist_reactor_t *reactor, int fd) { (void)reactor; (void)fd; return false; } +/** + * @brief Run the reactor event loop (WASI stub). + * @param reactor Ignored. + */ void cwist_reactor_run(cwist_reactor_t *reactor) { (void)reactor; } +/** + * @brief Register an fd for read events (WASI stub). + * @param reactor Ignored. + * @param fd Ignored. + * @param cb Ignored. + * @param payload Ignored. + * @param payload_size Ignored. + * @return Always false. + */ bool cwist_reactor_add(cwist_reactor_t *reactor, int fd, cwist_reactor_cb_t cb, const void *payload, size_t payload_size) { (void)reactor; @@ -64,6 +96,15 @@ bool cwist_reactor_add(cwist_reactor_t *reactor, int fd, cwist_reactor_cb_t cb, return false; } +/** + * @brief Register an fd for write events (WASI stub). + * @param reactor Ignored. + * @param fd Ignored. + * @param cb Ignored. + * @param payload Ignored. + * @param payload_size Ignored. + * @return Always false. + */ bool cwist_reactor_add_out(cwist_reactor_t *reactor, int fd, cwist_reactor_cb_t cb, const void *payload, size_t payload_size) { (void)reactor; @@ -74,16 +115,30 @@ bool cwist_reactor_add_out(cwist_reactor_t *reactor, int fd, cwist_reactor_cb_t return false; } +/** + * @brief Enqueue a posted task node (WASI stub). + * @param reactor Ignored. + * @param node Ignored. + * @return Always false. + */ bool cwist_reactor_post(cwist_reactor_t *reactor, cwist_reactor_post_t *node) { (void)reactor; (void)node; return false; } +/** + * @brief Request the reactor loop to stop (WASI stub). + * @param reactor Ignored. + */ void cwist_reactor_stop(cwist_reactor_t *reactor) { (void)reactor; } +/** + * @brief Destroy a reactor instance (WASI stub). + * @param reactor Ignored; performs no cleanup. + */ void cwist_reactor_destroy(cwist_reactor_t *reactor) { (void)reactor; } @@ -95,19 +150,42 @@ void cwist_reactor_destroy(cwist_reactor_t *reactor) { static cwist_metrics_registry_t *const g_wasi_metrics_sink = (cwist_metrics_registry_t *)(uintptr_t)1; +/** + * @brief Return the metrics registry singleton (WASI preview1 stub). + * @return A fixed non-NULL sentinel pointer; the sink is never dereferenced + * because metric increments are no-ops on this target. + */ cwist_metrics_registry_t *cwist_metrics_registry(void) { return g_wasi_metrics_sink; } +/** + * @brief Increment a metric counter (WASI preview1 stub). + * @param reg Ignored. + * @param id Ignored. + */ void cwist_metric_inc(cwist_metrics_registry_t *reg, cwist_metric_id_t id) { (void)reg; (void)id; } +/** + * @brief Fast monotonic clock in seconds (WASI preview1 stub). + * @return Always 0; the real writer_fast.c unit is not built for preview1. + */ uint32_t cwist_fast_monotonic_sec(void) { return 0; } +/** + * @brief Attempt a speculative vectored write (WASI preview1 stub). + * @param fd Ignored. + * @param iov Ignored. + * @param iovcnt Ignored. + * @param flags Ignored. + * @param total_sent Set to 0 when non-NULL. + * @retval CWIST_WRITE_ERR Always; no socket I/O exists on this target. + */ cwist_write_status_t cwist_http_sendmsg_speculative(int fd, struct iovec *iov, int iovcnt, int flags, size_t *total_sent) { (void)fd; @@ -121,16 +199,34 @@ cwist_write_status_t cwist_http_sendmsg_speculative(int fd, struct iovec *iov, i /* --- HTTPS/HTTP-2 upgrade paths: no TLS in a WASM host. ----------------- */ +/** + * @brief Check whether an HTTPS connection negotiates HTTP/2 (WASI stub). + * @param conn Ignored. + * @return Always false; no TLS or ALPN negotiation exists on this target. + */ bool cwist_https_connection_uses_http2(const cwist_https_connection *conn) { (void)conn; return false; } +/** + * @brief Receive an HTTP request from an HTTPS connection (WASI stub). + * @param conn Ignored. + * @return Always NULL; no TLS connection exists on this target. + */ cwist_http_request *cwist_https_receive_request(cwist_https_connection *conn) { (void)conn; return NULL; } +/** + * @brief Serve an HTTP/2 connection (WASI stub). + * @param conn Ignored. + * @param user_ctx Ignored. + * @param handler Ignored. + * @param hooks Ignored. + * @return An error with err_i16 set to -1; no HTTP/2 connection can run here. + */ cwist_error_t cwist_http2_serve_connection_ex(cwist_https_connection *conn, void *user_ctx, cwist_http2_request_handler_func handler, const cwist_http2_stream_hooks *hooks) { @@ -143,6 +239,10 @@ cwist_error_t cwist_http2_serve_connection_ex(cwist_https_connection *conn, void return err; } +/** + * @brief Return the gRPC HTTP/2 stream hooks (WASI stub). + * @return Always NULL; no HTTP/2 connection exists on this target. + */ const cwist_http2_stream_hooks *cwist_grpc_http2_hooks(void) { return NULL; } @@ -150,14 +250,33 @@ const cwist_http2_stream_hooks *cwist_grpc_http2_hooks(void) { /* H2 deferred-completion queue: reachable through async.c's Plan B handoff, * but a WASM host never runs an H2 connection, so req->h2_queue is always * NULL on this target and the stubs are never executed. */ +/** + * @brief Acquire a reference to the H2 deferred-completion queue (WASI stub). + * @param q Ignored. + * @return @p q unchanged; the queue is never active on this target. + */ cwist_h2_async_queue *cwist_h2_async_queue_acquire(cwist_h2_async_queue *q) { return q; } +/** + * @brief Release a reference to the H2 deferred-completion queue (WASI stub). + * @param q Ignored; performs no cleanup. + */ void cwist_h2_async_queue_release(cwist_h2_async_queue *q) { (void)q; } +/** + * @brief Enqueue a deferred H2 response (WASI stub). + * @param q Ignored. + * @param stream_id Ignored. + * @param req Ignored. + * @param send Ignored. + * @param res Ignored. + * @param send_owned Ignored. + * @return Always -1; the queue is never active on this target. + */ int cwist_h2_async_queue_enqueue(cwist_h2_async_queue *q, uint32_t stream_id, cwist_http_request *req, cwist_http_response *send, cwist_http_response *res, bool send_owned) { @@ -173,22 +292,43 @@ int cwist_h2_async_queue_enqueue(cwist_h2_async_queue *q, uint32_t stream_id, /* HTTPS connection send/close: reachable through async.c's completion path, * never executed -- a WASM host dispatches in memory and never wraps a TLS * connection. */ +/** + * @brief Close an HTTPS connection (WASI stub). + * @param conn Ignored; performs no cleanup. + */ void cwist_https_close_connection(cwist_https_connection *conn) { (void)conn; } +/** + * @brief Build the shared "HTTPS unavailable" error value. + * @return An error with err_i16 set to -1. + * @note File-local helper used by the HTTPS send/close stubs. + */ static cwist_error_t cwist_wasi_https_unavailable(void) { cwist_error_t err = {0}; err.error.err_i16 = -1; return err; } +/** + * @brief Send an HTTP response on an HTTPS connection (WASI stub). + * @param conn Ignored. + * @param res Ignored. + * @return An error with err_i16 set to -1; no TLS connection exists here. + */ cwist_error_t cwist_https_send_response(cwist_https_connection *conn, cwist_http_response *res) { (void)conn; (void)res; return cwist_wasi_https_unavailable(); } +/** + * @brief Send an HTTP response head on an HTTPS connection (WASI stub). + * @param conn Ignored. + * @param res Ignored. + * @return An error with err_i16 set to -1; no TLS connection exists here. + */ cwist_error_t cwist_https_send_response_head(cwist_https_connection *conn, cwist_http_response *res) { (void)conn; @@ -196,6 +336,13 @@ cwist_error_t cwist_https_send_response_head(cwist_https_connection *conn, return cwist_wasi_https_unavailable(); } +/** + * @brief Submit an HTTPS connection to the worker pool (WASI stub). + * @param conn Ignored. + * @param ctx Ignored. + * @param handler Ignored. + * @param user_ctx Ignored. + */ void https_pool_submit_conn(cwist_https_connection *conn, cwist_https_context *ctx, void (*handler)(cwist_https_connection *, void *), void *user_ctx) { (void)conn;