/* This program is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation; version 2 of the License. For a copy,
 * see http://www.gnu.org/licenses/gpl-2.0.html.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
 * GNU General Public License for more details.
 */

#include "config.h"

#ifdef ENABLE_HTTP2

#include <sys/types.h>
#include <stdlib.h>
#include <stdbool.h>
#include <string.h>
#include <unistd.h>
#include <limits.h>
#include <poll.h>
#include <fcntl.h>
#include <errno.h>
#include <sys/socket.h>
#include <pthread.h>
#include <nghttp2/nghttp2.h>
#include "global.h"
#include "http2.h"
#include "session.h"
#include "http.h"
#include "send.h"
#include "tls.h"
#include "log.h"
#include "client.h"
#include "workers.h"
#include "alternative.h"
#include "liblist.h"
#include "libstr.h"
#include "memdbg.h"

/* Maximum number of response headers we parse from the buffered HTTP/1.1 output */
#define H2_MAX_RESPONSE_HEADERS 64

typedef struct {
	pthread_t           thread;
	int                 wakeup_pipe[2];
	volatile bool       running;

	/* Connection array (protected by conn_mutex) */
	t_http2_connection  **connections;
	int                 conn_count;
	int                 conn_capacity;
	pthread_mutex_t     conn_mutex;

	/* Pending connection queue: new connections waiting to be added */
	t_http2_connection  **pending_conns;
	int                 pending_count;
	int                 pending_capacity;

	/* Response queue (workers push, event loop pops) */
	t_h2_response_item *response_head;
	t_h2_response_item *response_tail;
	pthread_mutex_t     response_mutex;

	/* Zombie connections: closed but still have pending worker responses.
	 * We keep them alive (not free'd) until all workers have finished
	 * and their responses have been drained from the queue.  This prevents
	 * use-after-free when a connection is force-closed (H2_CLOSING_TIMEOUT)
	 * while workers still hold item->conn pointers.
	 */
	t_http2_connection  **zombies;
	int                 zombie_count;
	int                 zombie_capacity;

	t_config            *config;

	/* Graceful shutdown: drain pending requests before exit.
	 * All three fields are written by the main/signal thread in
	 * h2_graceful_shutdown() and read by the event loop thread.
	 * volatile ensures visibility across threads (sufficient for
	 * bool/time_t on all supported platforms).
	 */
	volatile bool       draining;
	volatile bool       goaway_sent;
	volatile time_t     drain_deadline;
	volatile bool       shutdown_done;  /* guard against double pthread_join */
} t_h2_event_loop;

typedef struct {
	const char *data;
	size_t length;
	size_t offset;
	char *response_buf;   /* owns the full response buffer */
} t_h2_data_source;

/* Non-blocking TLS read for the event loop.
 * Unlike tls_receive(), this does NOT loop on WANT_READ.  A single
 * mbedtls_ssl_read() call is made.  If the TLS record is incomplete
 * (WANT_READ), we return 0 so the event loop can continue servicing
 * other connections and come back when poll() fires again.
 */
static int h2_tls_receive(mbedtls_ssl_context *context, char *buffer, unsigned int maxlength) {
	int result;

	result = mbedtls_ssl_read(context, (unsigned char*)buffer, maxlength);
	if ((result == MBEDTLS_ERR_SSL_WANT_READ) ||
	    (result == MBEDTLS_ERR_SSL_WANT_WRITE)) {
		return 0;  /* Incomplete TLS record — come back later */
	}
	if (result == MBEDTLS_ERR_SSL_PEER_CLOSE_NOTIFY) {
		return -1;  /* Peer sent TLS close_notify — connection is done */
	}
	if (result <= 0) {
		/* result == 0: TCP EOF without TLS close_notify (abrupt disconnect)
		 * result < 0:  TLS error (CONN_RESET, etc.)
		 * Both mean the connection should be closed.
		 */
		return -1;
	}

	return result;
}

/* Configure socket for the event loop.
 *
 * O_NONBLOCK: required for correct EAGAIN handling with mbedTLS.
 *   mbedTLS net_would_block() only recognises EAGAIN on non-blocking
 *   sockets (checks O_NONBLOCK via fcntl).  With SO_SNDTIMEO on a
 *   blocking socket, write() returns EAGAIN after the timeout, but
 *   mbedTLS misclassifies it as MBEDTLS_ERR_NET_SEND_FAILED — a hard
 *   error that kills the HTTP/2 connection mid-transfer.  O_NONBLOCK
 *   makes mbedTLS return WANT_WRITE, which h2_send_callback translates
 *   to NGHTTP2_ERR_WOULDBLOCK.  The event loop then waits for POLLOUT.
 *
 * SO_RCVTIMEO = 200ms: prevents blocking the event loop on a partial
 *   TLS record.  h2_tls_receive() returns 0 on WANT_READ.
 *   Note: SO_RCVTIMEO has no effect when O_NONBLOCK is set (read()
 *   returns EAGAIN immediately), but we keep it as a safety net in
 *   case O_NONBLOCK is cleared elsewhere.
 */
static void h2_set_socket_timeouts(int fd) {
	struct timeval tv;
	int flags;

	/* Non-blocking writes: prevents mbedTLS EAGAIN misclassification */
	flags = fcntl(fd, F_GETFL);
	if (flags != -1) {
		fcntl(fd, F_SETFL, flags | O_NONBLOCK);
	}

	/* Read timeout (safety net — O_NONBLOCK makes this a no-op) */
	tv.tv_sec = 0;
	tv.tv_usec = 200 * 1000;  /* 200ms */
	setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
}

/* Forward declarations */
static void h2_mark_closing(t_http2_connection *conn);
static ssize_t h2_send_callback(nghttp2_session *ng_session, const uint8_t *data,
                                size_t length, int flags, void *user_data);
static int on_frame_recv_callback(nghttp2_session *ng_session, const nghttp2_frame *frame,
                                  void *user_data);
static int on_begin_headers_callback(nghttp2_session *ng_session, const nghttp2_frame *frame,
                                     void *user_data);
static int on_header_callback(nghttp2_session *ng_session, const nghttp2_frame *frame,
                              const uint8_t *name, size_t namelen,
                              const uint8_t *value, size_t valuelen,
                              uint8_t flags, void *user_data);
static int on_data_chunk_recv_callback(nghttp2_session *ng_session, uint8_t flags,
                                       int32_t stream_id, const uint8_t *data, size_t len,
                                       void *user_data);
static int on_stream_close_callback(nghttp2_session *ng_session, int32_t stream_id,
                                    uint32_t error_code, void *user_data);

/* ========================================================================
 *  Event loop state (module-level singleton)
 * ======================================================================== */

static t_h2_event_loop h2_loop;

/* Active H2 worker count.  Incremented by h2_drain_dispatch_queue()
 * after successful start_worker(), decremented by h2_worker_process()
 * on every exit path.  Accessed from event loop + worker threads,
 * so we use GCC atomic builtins for thread safety.
 */
static volatile int h2_active_workers = 0;

/* ========================================================================
 *  Stream management
 * ======================================================================== */

static t_http2_stream *find_stream(t_http2_connection *conn, int32_t stream_id) {
	t_http2_stream *stream = conn->streams;

	while (stream != NULL) {
		if (stream->stream_id == stream_id) {
			return stream;
		}
		stream = stream->next;
	}

	return NULL;
}

static t_http2_stream *create_stream(t_http2_connection *conn, int32_t stream_id) {
	t_http2_stream *stream;

	if (conn->active_streams >= conn->base_session->binding->h2_max_concurrent_streams) {
		return NULL;
	}

	if ((stream = (t_http2_stream*)calloc(1, sizeof(t_http2_stream))) == NULL) {
		return NULL;
	}

	stream->stream_id = stream_id;
	stream->next = conn->streams;
	conn->streams = stream;
	conn->active_streams++;

	return stream;
}

static void free_h2_headers(t_http_header *headers) {
	t_http_header *next;

	while (headers != NULL) {
		next = headers->next;
		free(headers->data);
		headers->data = NULL;
		free(headers);
		headers = next;
	}
}

static void destroy_stream(t_http2_connection *conn, int32_t stream_id) {
	t_http2_stream *stream = conn->streams, *prev = NULL;

	while (stream != NULL) {
		if (stream->stream_id == stream_id) {
			if (prev == NULL) {
				conn->streams = stream->next;
			} else {
				prev->next = stream->next;
			}

			free(stream->method);
			stream->method = NULL;
			free(stream->path);
			stream->path = NULL;
			free(stream->authority);
			stream->authority = NULL;
			free(stream->scheme);
			stream->scheme = NULL;
			free(stream->body);
			stream->body = NULL;
			free_h2_headers(stream->headers);
			stream->headers = NULL;

			free(stream);
			conn->active_streams--;
			return;
		}

		prev = stream;
		stream = stream->next;
	}
}

static void destroy_all_streams(t_http2_connection *conn) {
	while (conn->streams != NULL) {
		destroy_stream(conn, conn->streams->stream_id);
	}
}

/* ========================================================================
 *  Header helpers
 * ======================================================================== */

static int add_stream_header(t_http2_stream *stream, const uint8_t *name, size_t namelen,
                             const uint8_t *value, size_t valuelen) {
	t_http_header *hdr;
	size_t datalen;

	if ((hdr = (t_http_header*)malloc(sizeof(t_http_header))) == NULL) {
		return -1;
	}

	if (namelen > SIZE_MAX - 3 - valuelen) {
		free(hdr);
		hdr = NULL;
		return -1;
	}
	datalen = namelen + 2 + valuelen;
	if ((hdr->data = (char*)malloc(datalen + 1)) == NULL) {
		free(hdr);
		hdr = NULL;
		return -1;
	}

	memcpy(hdr->data, name, namelen);
	hdr->data[namelen] = ':';
	hdr->data[namelen + 1] = ' ';
	memcpy(hdr->data + namelen + 2, value, valuelen);
	hdr->data[datalen] = '\0';

	hdr->length = (int)datalen;
	hdr->value_offset = (int)(namelen + 2);
	hdr->next = stream->headers;
	stream->headers = hdr;

	return 0;
}

static t_req_method parse_method(const char *method) {
	if (strcmp(method, "GET") == 0) return GET;
	if (strcmp(method, "POST") == 0) return POST;
	if (strcmp(method, "HEAD") == 0) return HEAD;
	if (strcmp(method, "PUT") == 0) return PUT;
	if (strcmp(method, "DELETE") == 0) return DELETE;
	if (strcmp(method, "CONNECT") == 0) return CONNECT;
	if (strcmp(method, "TRACE") == 0) return TRACE;
	if (strcmp(method, "OPTIONS") == 0) return unsupported;
	if (strcmp(method, "PROPFIND") == 0) return unsupported;
	if (strcmp(method, "PROPPATCH") == 0) return unsupported;
	if (strcmp(method, "MKCOL") == 0) return unsupported;
	if (strcmp(method, "PATCH") == 0) return unsupported;
	return unknown;
}

/* ========================================================================
 *  Response data source for nghttp2
 * ======================================================================== */

static ssize_t h2_data_source_read(nghttp2_session *ng_session, int32_t stream_id,
                                    uint8_t *buf, size_t length, uint32_t *data_flags,
                                    nghttp2_data_source *source, void *user_data) {
	t_h2_data_source *src = (t_h2_data_source*)source->ptr;
	size_t remaining, to_copy;

	(void)user_data;

	remaining = src->length - src->offset;
	to_copy = remaining < length ? remaining : length;

	if (to_copy > 0) {
		memcpy(buf, src->data + src->offset, to_copy);
		src->offset += to_copy;
	}

	if (src->offset >= src->length) {
		*data_flags |= NGHTTP2_DATA_FLAG_EOF;
		/* Clear stream user data before freeing, so on_stream_close_callback
		 * does not double-free when the stream closes after EOF.
		 */
		nghttp2_session_set_stream_user_data(ng_session, stream_id, NULL);
		free(src->response_buf);
		src->response_buf = NULL;
		free(src);
		source->ptr = NULL;
	}

	return (ssize_t)to_copy;
}

/* ========================================================================
 *  Response submission (used by event loop after worker returns response)
 * ======================================================================== */

/* Submit a buffered HTTP/1.1 response to nghttp2.
 * The response_buf contains "HTTP/1.1 200 OK\r\nHeaders\r\n\r\nBody".
 * We parse it into structured headers and submit via nghttp2_submit_response().
 */
static int h2_submit_response_from_buf(t_http2_connection *conn, int32_t stream_id,
                                        char *response_buf, size_t response_size,
                                        int return_code) {
	char *buf, *header_end, *line, *next_line, *colon, *space, *val_start;
	size_t header_len, body_offset, name_len, val_len, i;
	nghttp2_nv nv[H2_MAX_RESPONSE_HEADERS];
	int nv_count = 0;
	char status_str[4];
	nghttp2_data_provider data_prd;
	t_h2_data_source *data_src;
	const char *body_data;
	size_t body_len;

	buf = response_buf;

	/* Handle empty response */
	if ((buf == NULL) || (response_size == 0)) {
		snprintf(status_str, sizeof(status_str), "%d", return_code);

		nv[0].name = (uint8_t*)":status";
		nv[0].namelen = 7;
		nv[0].value = (uint8_t*)status_str;
		nv[0].valuelen = strlen(status_str);
		nv[0].flags = NGHTTP2_NV_FLAG_NO_COPY_NAME;

		nghttp2_submit_headers(conn->ng_session, NGHTTP2_FLAG_END_STREAM,
		                       stream_id, NULL, nv, 1, NULL);
		free(response_buf);
		response_buf = NULL;
		return 0;
	}

	/* Find end of headers: "\r\n\r\n" */
	header_end = (char*)strnstr(buf, "\r\n\r\n", (int)response_size);
	if (header_end == NULL) {
		header_len = response_size;
		body_data = NULL;
		body_len = 0;
	} else {
		header_len = (size_t)(header_end - buf);
		body_offset = header_len + 4;
		body_data = buf + body_offset;
		body_len = response_size - body_offset;
	}

	/* Parse status from first line: "HTTP/1.1 200 OK\r\n" */
	line = buf;
	next_line = (char*)strnstr(line, "\r\n", (int)header_len);
	if (next_line == NULL) {
		snprintf(status_str, sizeof(status_str), "%d", return_code);
	} else {
		space = memchr(line, ' ', (size_t)(next_line - line));
		if ((space != NULL) && ((next_line - space) > 3)) {
			memcpy(status_str, space + 1, 3);
			status_str[3] = '\0';
		} else {
			snprintf(status_str, sizeof(status_str), "%d", return_code);
		}
		line = next_line + 2;
	}

	/* Add :status pseudo-header */
	nv[nv_count].name = (uint8_t*)":status";
	nv[nv_count].namelen = 7;
	nv[nv_count].value = (uint8_t*)status_str;
	nv[nv_count].valuelen = strlen(status_str);
	nv[nv_count].flags = NGHTTP2_NV_FLAG_NONE;
	nv_count++;

	/* Parse remaining header lines */
	while ((line < buf + header_len) && (nv_count < H2_MAX_RESPONSE_HEADERS)) {
		next_line = (char*)strnstr(line, "\r\n", (int)(buf + header_len - line));
		if (next_line == NULL) {
			next_line = buf + header_len;
		}

		if (next_line == line) {
			break;
		}

		colon = memchr(line, ':', (size_t)(next_line - line));
		if (colon == NULL) {
			line = next_line + 2;
			continue;
		}

		name_len = (size_t)(colon - line);
		val_start = colon + 1;

		while ((val_start < next_line) && (*val_start == ' ')) {
			val_start++;
		}
		val_len = (size_t)(next_line - val_start);

		/* Skip HTTP/2 forbidden hop-by-hop headers (RFC 9113 Section 8.2.2) */
		if (((name_len == 10) && (strncasecmp(line, "connection", 10) == 0)) ||
		    ((name_len == 17) && (strncasecmp(line, "transfer-encoding", 17) == 0)) ||
		    ((name_len == 10) && (strncasecmp(line, "keep-alive", 10) == 0)) ||
		    ((name_len == 7)  && (strncasecmp(line, "upgrade", 7) == 0)) ||
		    ((name_len == 16) && (strncasecmp(line, "proxy-connection", 16) == 0))) {
			line = next_line + 2;
			continue;
		}

		/* RFC 9113 Section 8.2: header field names MUST be lowercase */
		for (i = 0; i < name_len; i++) {
			if ((line[i] >= 'A') && (line[i] <= 'Z')) {
				line[i] = line[i] + ('a' - 'A');
			}
		}

		nv[nv_count].name = (uint8_t*)line;
		nv[nv_count].namelen = name_len;
		nv[nv_count].value = (uint8_t*)val_start;
		nv[nv_count].valuelen = val_len;
		nv[nv_count].flags = NGHTTP2_NV_FLAG_NONE;
		nv_count++;

		line = next_line + 2;
	}

	/* Submit response with body data provider */
	if ((body_data != NULL) && (body_len > 0)) {
		if ((data_src = (t_h2_data_source*)malloc(sizeof(t_h2_data_source))) == NULL) {
			free(response_buf);
			response_buf = NULL;
			return -1;
		}
		data_src->data = body_data;
		data_src->length = body_len;
		data_src->offset = 0;
		data_src->response_buf = response_buf;  /* Transfer ownership */

		data_prd.source.ptr = data_src;
		data_prd.read_callback = h2_data_source_read;

		if (nghttp2_submit_response(conn->ng_session, stream_id,
		                             nv, (size_t)nv_count, &data_prd) != 0) {
			free(data_src->response_buf);
			data_src->response_buf = NULL;
			free(data_src);
			data_src = NULL;
			return -1;
		}
		/* Track data source so on_stream_close_callback can free it
		 * if the stream is reset before all data is read.
		 */
		nghttp2_session_set_stream_user_data(conn->ng_session, stream_id, data_src);
	} else {
		/* Headers-only response (e.g. HEAD) — submit first, then free buffer.
		 * nv entries point into response_buf, so it must stay alive until
		 * nghttp2_submit_response has copied the header data.
		 */
		if (nghttp2_submit_response(conn->ng_session, stream_id,
		                             nv, (size_t)nv_count, NULL) != 0) {
			free(response_buf);
			response_buf = NULL;
			return -1;
		}
		free(response_buf);
		response_buf = NULL;
	}

	return 0;
}

/* ========================================================================
 *  Session initialization for worker (from work item, not from stream)
 * ======================================================================== */

static int h2_init_session_from_work_item(t_session *session, t_h2_work_item *item) {
	session->time = time(NULL);
	session->cgi_type = no_cgi;
	session->cgi_handler = NULL;
	session->fcgi_server = NULL;
	session->path_info = NULL;
	session->vars = NULL;
	session->extension = NULL;
	session->file_on_disk = NULL;
	session->mimetype = NULL;
	session->parsing_okay = true;
	session->keep_alive = false;
	session->header_sent = false;
	session->data_sent = false;
	session->uri_is_dir = false;
	session->encode_gzip = false;
	session->alias = NULL;
	session->script_alias = NULL;
	session->local_user = NULL;
	session->remote_user = NULL;
	session->http_auth = no_auth;
	session->directory = NULL;
	session->handling_error = false;
	session->reason_for_403 = "";
	session->cookies = NULL;
	session->bytes_sent = 0;
	session->output_size = 0;
	session->return_code = 200;
	session->error_cause = ec_NONE;
	session->error_code = -1;
	session->log_request = true;
	session->tempdata = NULL;
	session->uploaded_file = NULL;
	session->location = NULL;
	session->send_date = true;
	session->send_expires = false;
	session->expires = -1;
	session->caco_private = true;
	session->cause_of_30x = missing_slash;
	session->letsencrypt_auth_request = false;
	session->throttle = 0;
	session->bytecounter = 0;
	session->host = session->config->first_host;
	session->host_copied = false;
#ifdef ENABLE_TOOLKIT
	session->toolkit_fastcgi = NULL;
#endif

	/* Map work item fields to session */
	if ((item->method == NULL) || (item->path == NULL)) {
		return -1;
	}
	session->method = item->method;
	session->request_method = parse_method(item->method);

	if ((session->request_uri = strdup(item->path)) == NULL) {
		return -1;
	}

	if ((session->uri = strdup(item->path)) == NULL) {
		free(session->request_uri);
		session->request_uri = NULL;
		return -1;
	}

	if (register_tempdata(&(session->tempdata), session->uri, tc_data) == -1) {
		free(session->uri);
		session->uri = NULL;
		free(session->request_uri);
		session->request_uri = NULL;
		return -1;
	}

	session->uri_len = (int)strlen(item->path);
	if (item->authority != NULL) {
		char *hostname;

		if ((hostname = strdup(item->authority)) == NULL) {
			free(session->request_uri);
			session->request_uri = NULL;
			return -1;
		}

		if (register_tempdata(&(session->tempdata), hostname, tc_data) == -1) {
			free(hostname);
			free(session->request_uri);
			session->request_uri = NULL;
			return -1;
		}

		session->hostname = hostname;
	}
	session->http_version = "HTTP/2.0";
	session->header_length = 0;

	/* Borrow headers and body from work item */
	session->http_headers = item->headers;
	session->cookies = get_http_header("Cookie:", session->http_headers);
	session->body = item->body;
	session->content_length = (long)item->body_size;

	/* Count each HTTP/2 stream as a kept-alive request.
	 * Mirrors the kept_alive++ in parse_request() for HTTP/1.x.
	 */
	session->kept_alive++;

	/* HTTP/2 session state */
	session->is_http2 = true;
	session->h2_stream_id = item->stream_id;
	session->h2_connection = item->conn;
	session->h2_tls_version = item->tls_version;
	session->h2_tls_cipher = item->tls_cipher;
	session->h2_response_buf = NULL;
	session->h2_response_size = 0;
	session->h2_response_capacity = 0;

	return 0;
}

/* Clean up per-request session fields after worker processing */
static void h2_cleanup_worker_session(t_session *session) {
	free(session->request_uri);
	session->request_uri = NULL;
	/* Do NOT free session->uri here — it is registered in tempdata
	 * (either the original strdup or a UrlToolkit rewrite) and will
	 * be freed by remove_tempdata() below.
	 */
	session->uri = NULL;
	/* Do NOT free session->hostname here — if it was strdup'd by
	 * remove_port_from_hostname(), it is registered in tempdata and
	 * will be freed by remove_tempdata() below.  Otherwise it points
	 * into the header buffer and must not be freed.
	 */
	session->hostname = NULL;
	free(session->file_on_disk);
	session->file_on_disk = NULL;
	free(session->path_info);
	session->path_info = NULL;
	free(session->location);
	session->location = NULL;

	/* Do NOT free h2_response_buf — it's transferred to the response item */

	if (session->uploaded_file != NULL) {
		unlink(session->uploaded_file);
		free(session->uploaded_file);
		session->uploaded_file = NULL;
	}

	/* Decrement directory client counter (mirrors reset_session in session.c) */
	if (session->directory != NULL) {
		pthread_mutex_lock(&(session->directory->client_mutex));
		if (session->part_of_dirspeed) {
			if (--session->directory->nr_of_clients == 0) {
				session->directory->session_speed = session->directory->upload_speed;
			} else {
				session->directory->session_speed = session->directory->upload_speed / session->directory->nr_of_clients;
			}
		}
		pthread_mutex_unlock(&(session->directory->client_mutex));
	}

	remove_tempdata(session->tempdata);
	session->tempdata = NULL;

#ifdef ENABLE_TOOLKIT
	/* Free per-request toolkit rules (mirrors reset_session in session.c) */
	if ((session->host_copied) && (session->host != NULL)) {
		if (session->host->toolkit_rules_user != NULL) {
			free(session->host->toolkit_rules_user);
		}
		remove_charlist(&(session->host->toolkit_rules_user_str));
	}
#endif

	if (session->host_copied) {
		free(session->host);
		session->host_copied = false;
	}
	session->host = NULL;

	check_free(session->request);
	session->request = NULL;
#ifdef CIFS
	check_free(session->extension);
	session->extension = NULL;
#endif

	check_clear_free(session->local_user, CHECK_USE_STRLEN);
	session->local_user = NULL;
	check_clear_free(session->remote_user, CHECK_USE_STRLEN);
	session->remote_user = NULL;

	/* Clear borrowed pointers */
	session->http_headers = NULL;
	session->body = NULL;
	session->method = NULL;
	session->http_version = NULL;
}

/* ========================================================================
 *  Deep-copy stream data into work item
 * ======================================================================== */

static t_http_header *clone_headers(t_http_header *src) {
	t_http_header *dst_head = NULL, *dst_tail = NULL, *copy;

	while (src != NULL) {
		if ((copy = (t_http_header*)malloc(sizeof(t_http_header))) == NULL) {
			free_h2_headers(dst_head);
			return NULL;
		}
		if ((copy->data = (char*)malloc((size_t)src->length + 1)) == NULL) {
			free(copy);
			copy = NULL;
			free_h2_headers(dst_head);
			return NULL;
		}
		memcpy(copy->data, src->data, (size_t)src->length + 1);
		copy->length = src->length;
		copy->value_offset = src->value_offset;
		copy->next = NULL;

		if (dst_tail == NULL) {
			dst_head = dst_tail = copy;
		} else {
			dst_tail->next = copy;
			dst_tail = copy;
		}
		src = src->next;
	}

	return dst_head;
}

static void free_work_item(t_h2_work_item *item) {
	if (item == NULL) {
		return;
	}

	check_free(item->method);
	item->method = NULL;

	check_free(item->path);
	item->path = NULL;

	check_free(item->authority);
	item->authority = NULL;

	check_free(item->body);
	item->body = NULL;

	free_h2_headers(item->headers);
	item->headers = NULL;

	free(item);
}

static t_h2_work_item *h2_clone_stream_to_work_item(t_http2_connection *conn,
                                                      t_http2_stream *stream) {
	t_h2_work_item *item;

	if ((item = (t_h2_work_item*)calloc(1, sizeof(t_h2_work_item))) == NULL) {
		return NULL;
	}

	item->conn = conn;
	item->stream_id = stream->stream_id;
	item->config = conn->base_session->config;
	item->binding = conn->base_session->binding;
	item->ip_address = conn->base_session->ip_address;
	item->tls_version = tls_version_string(&(conn->base_session->tls_context));
	item->tls_cipher = tls_cipher_string(&(conn->base_session->tls_context));

	item->method = NULL;
	item->path = NULL;
	item->authority = NULL;
	item->headers = NULL;
	item->body = NULL;

	/* Deep-copy pseudo-headers */
	if (stream->method != NULL) {
		if ((item->method = strdup(stream->method)) == NULL) {
			goto fail;
		}
	}
	if (stream->path != NULL) {
		if ((item->path = strdup(stream->path)) == NULL) {
			goto fail;
		}
	}
	if (stream->authority != NULL) {
		if ((item->authority = strdup(stream->authority)) == NULL) {
			goto fail;
		}
	}

	/* Deep-copy headers */
	item->headers = clone_headers(stream->headers);
	if ((stream->headers != NULL) && (item->headers == NULL)) {
		goto fail;
	}

	/* Deep-copy body */
	if ((stream->body != NULL) && (stream->body_size > 0)) {
		if ((item->body = (char*)malloc(stream->body_size + 1)) == NULL) {
			goto fail;
		}
		memcpy(item->body, stream->body, stream->body_size);
		item->body[stream->body_size] = '\0';
		item->body_size = stream->body_size;
	}

	return item;

fail:
	free_work_item(item);

	return NULL;
}

/* Write in-memory PUT body to a temp file in upload_directory,
 * mirroring the store_on_disk path in fetch_request() for HTTP/1.1.
 * Returns 0 on success (session->uploaded_file is set), -1 on error.
 */
static int h2_write_put_tempfile(t_session *session, const char *body, size_t body_size) {
	int fd;

	if (session->config->upload_directory == NULL) {
		return -1;
	}

	if ((session->uploaded_file = (char*)malloc(session->config->upload_directory_len + 15)) == NULL) {
		return -1;
	}

	memcpy(session->uploaded_file, session->config->upload_directory, session->config->upload_directory_len);
	memcpy(session->uploaded_file + session->config->upload_directory_len, "/upload_XXXXXX", 15);

	if ((fd = mkstemp(session->uploaded_file)) == -1) {
		free(session->uploaded_file);
		session->uploaded_file = NULL;
		return -1;
	}

	if (body_size > 0) {
		if (write_buffer(fd, body, (long)body_size) == -1) {
			close(fd);
			unlink(session->uploaded_file);
			free(session->uploaded_file);
			session->uploaded_file = NULL;
			return -1;
		}
	}

	close(fd);

	return 0;
}

/* ========================================================================
 *  Worker-facing function: process a dispatched H2 request
 * ======================================================================== */

void h2_worker_process(t_h2_work_item *item) {
	t_session session;
	t_h2_response_item *response;
	int result;

	/* Initialize a stack-allocated session for request processing */
	memset(&session, 0, sizeof(session));
	session.config = item->config;
	session.binding = item->binding;
	session.ip_address = item->ip_address;
	session.client_socket = -1;  /* Worker must NEVER touch the socket */
	session.socket_open = true;  /* Logical — response buffering needs this */
	session.request_limit = true;
	session.force_quit = false;
	session.last_host = NULL;
	session.request = NULL;
	session.buffer_size = 0;
	session.bytes_in_buffer = 0;
#ifdef ENABLE_RPROXY
	session.rproxy_kept_alive = false;
#endif

	/* Validate required pseudo-headers */
	if ((item->method == NULL) || (item->path == NULL)) {
		/* Queue a 400 response */
		if ((response = (t_h2_response_item*)calloc(1, sizeof(t_h2_response_item))) != NULL) {
			response->conn = item->conn;
			response->stream_id = item->stream_id;
			response->return_code = 400;
			response->log_request = false;

			pthread_mutex_lock(&h2_loop.response_mutex);
			if (h2_loop.response_tail != NULL) {
				h2_loop.response_tail->next = response;
			} else {
				h2_loop.response_head = response;
			}
			h2_loop.response_tail = response;
			pthread_mutex_unlock(&h2_loop.response_mutex);

			if (write(h2_loop.wakeup_pipe[1], "R", 1) == -1) {
				/* Best effort — event loop will pick up on next poll timeout */
			}
		}
		free_work_item(item);
		goto done;
	}

	/* HTTP/2 PUT handling: body arrives via DATA frames and is buffered
	 * in memory before the worker runs. Disk-buffering in the nghttp2
	 * callback is not feasible (callbacks run on the event-loop thread;
	 * blocking I/O would stall all connections on that loop).
	 *
	 * Hugo Leisink: "buffering large PUT requests in memory is a recipe
	 * for a DoS-attack." Therefore:
	 * 1. max_upload_size is enforced here as an early gate (413 if exceeded).
	 * 2. Bodies within the limit are written to a temp file in
	 *    upload_directory by h2_write_put_tempfile(), mirroring what
	 *    fetch_request() does for HTTP/1.1. session->uploaded_file is set
	 *    before handle_request() is called, so handle_put_request() in
	 *    target.c works unchanged.
	 */
	if (strcmp(item->method, "PUT") == 0) {
		if ((item->binding->max_upload_size > 0) &&
		    ((long)item->body_size > item->binding->max_upload_size)) {
			if ((response = (t_h2_response_item*)calloc(1, sizeof(t_h2_response_item))) != NULL) {
				response->conn = item->conn;
				response->stream_id = item->stream_id;
				response->return_code = 413;
				response->log_request = false;

				pthread_mutex_lock(&h2_loop.response_mutex);
				if (h2_loop.response_tail != NULL) {
					h2_loop.response_tail->next = response;
				} else {
					h2_loop.response_head = response;
				}
				h2_loop.response_tail = response;
				pthread_mutex_unlock(&h2_loop.response_mutex);

				if (write(h2_loop.wakeup_pipe[1], "R", 1) == -1) {
					/* Best effort */
				}
			}
			free_work_item(item);
			goto done;
		}
	}

	if (h2_init_session_from_work_item(&session, item) != 0) {
		/* OOM during session init — queue 500 response */
		if ((response = (t_h2_response_item*)calloc(1, sizeof(t_h2_response_item))) != NULL) {
			response->conn = item->conn;
			response->stream_id = item->stream_id;
			response->return_code = 500;
			response->log_request = false;

			pthread_mutex_lock(&h2_loop.response_mutex);
			if (h2_loop.response_tail != NULL) {
				h2_loop.response_tail->next = response;
			} else {
				h2_loop.response_head = response;
			}
			h2_loop.response_tail = response;
			pthread_mutex_unlock(&h2_loop.response_mutex);

			if (write(h2_loop.wakeup_pipe[1], "R", 1) == -1) {
				/* Best effort */
			}
		}
		h2_cleanup_worker_session(&session);
		free_work_item(item);
		goto done;
	}

	/* Write PUT body to temp file so handle_put_request() finds it */
	if ((item->method != NULL) && (strcmp(item->method, "PUT") == 0)) {
		if (h2_write_put_tempfile(&session, item->body, item->body_size) == -1) {
			log_error_session(&session, "can't create temporary file for PUT request");
			if ((response = (t_h2_response_item*)calloc(1, sizeof(t_h2_response_item))) != NULL) {
				response->conn = item->conn;
				response->stream_id = item->stream_id;
				response->return_code = 500;
				response->log_request = false;

				pthread_mutex_lock(&h2_loop.response_mutex);
				if (h2_loop.response_tail != NULL) {
					h2_loop.response_tail->next = response;
				} else {
					h2_loop.response_head = response;
				}
				h2_loop.response_tail = response;
				pthread_mutex_unlock(&h2_loop.response_mutex);

				if (write(h2_loop.wakeup_pipe[1], "R", 1) == -1) {
					/* Best effort */
				}
			}
			h2_cleanup_worker_session(&session);
			free_work_item(item);
			goto done;
		}
	}

	/* Process the request through the standard Hiawatha pipeline */
#ifdef ENABLE_DEBUG
	session.current_task = "fetch & parse HTTP/2 request";
#endif
	result = handle_request(&session);
	handle_request_result(&session, result);

	/* Flush buffered output into h2_response_buf */
	send_buffer(&session, NULL, 0);

	/* Build response item for event loop */
	if ((response = (t_h2_response_item*)calloc(1, sizeof(t_h2_response_item))) == NULL) {
		/* OOM — response will be lost, connection will eventually timeout */
		free(session.h2_response_buf);
		session.h2_response_buf = NULL;
		h2_cleanup_worker_session(&session);
		free_work_item(item);
		goto done;
	}

	response->conn = item->conn;
	response->stream_id = item->stream_id;
	response->response_buf = session.h2_response_buf;
	response->response_size = session.h2_response_size;
	response->return_code = session.return_code;
	response->log_request = session.log_request;
	response->bytes_sent = session.bytes_sent;
	response->request_method = session.request_method;
	if (session.request_uri != NULL) {
		response->log_uri = strdup(session.request_uri);
		if (response->log_uri == NULL) {
			response->log_request = false;
		}
	}
	response->next = NULL;

	/* Detach response_buf from session — ownership transfers to response item */
	session.h2_response_buf = NULL;
	session.h2_response_size = 0;
	session.h2_response_capacity = 0;

	/* Queue response for event loop */
	pthread_mutex_lock(&h2_loop.response_mutex);
	if (h2_loop.response_tail != NULL) {
		h2_loop.response_tail->next = response;
	} else {
		h2_loop.response_head = response;
	}
	h2_loop.response_tail = response;
	pthread_mutex_unlock(&h2_loop.response_mutex);

	/* Wake up event loop */
	if (write(h2_loop.wakeup_pipe[1], "R", 1) == -1) {
		/* Best effort */
	}

	/* Cleanup */
	h2_cleanup_worker_session(&session);
	free_work_item(item);

done:
	__sync_fetch_and_sub(&h2_active_workers, 1);
}

/* ========================================================================
 *  Dispatch to worker thread pool
 * ======================================================================== */

static int h2_dispatch_to_worker(t_h2_work_item *item) {
	t_session *wrapper;

	/* Allocate a lightweight wrapper session for start_worker() */
	if ((wrapper = (t_session*)malloc(sizeof(t_session))) == NULL) {
		return -1;
	}

	/* Minimal init — only what start_worker/connection_handler needs */
	memset(wrapper, 0, sizeof(t_session));
	wrapper->config = item->config;
	wrapper->binding = item->binding;
	wrapper->ip_address = item->ip_address;
	wrapper->client_socket = -1;
	wrapper->is_h2_work = true;
	wrapper->h2_work_item = item;

	if (start_worker(wrapper) != 0) {
		free(wrapper);
		wrapper = NULL;
		return -1;
	}

	return 0;
}

/* ========================================================================
 *  Bounded dispatch queue
 *
 *  Problem: on_frame_recv_callback() fires for every completed stream
 *  in a single nghttp2_session_recv() pass.  With 100 connections × 32
 *  concurrent streams, all 3200 requests can arrive in one poll cycle.
 *  Direct dispatch via start_worker() → pthread_create() fails when the
 *  OS thread limit is exceeded → RST_STREAM(INTERNAL_ERROR).
 *
 *  Solution: enqueue completed requests per connection, drain greedily
 *  from the event loop.  start_worker() failure stops the drain; items
 *  remain queued and are retried in the next cycle.
 *
 *  Thread safety: only the event loop thread touches these queues.
 *  Workers interact solely via the response queue (response_mutex).
 * ======================================================================== */

/* Enqueue a completed request for deferred worker dispatch.
 * Called from on_frame_recv_callback() when END_STREAM is received.
 * Returns -1 if queue is full (caller must RST_STREAM the stream).
 * pending_requests is incremented here, not at dispatch time, so that
 * shutdown drain and zombie lifecycle correctly track queued items.
 */
static int h2_enqueue_request(t_http2_connection *conn, t_h2_work_item *item) {
	if (conn->queued_count >= H2_MAX_QUEUED_PER_CONN) {
		return -1;
	}

	item->next = NULL;
	if (conn->queue_tail != NULL) {
		conn->queue_tail->next = item;
	} else {
		conn->queue_head = item;
	}
	conn->queue_tail = item;
	conn->queued_count++;
	conn->pending_requests++;
	conn->requests_served++;

	return 0;
}

/* Free all queued items without dispatching them.  Decrements
 * pending_requests for each item.  Called from:
 * - h2_close_connection(): queued items were never sent to workers
 * - h2_event_loop_shutdown(): event loop stopped, no workers will pick up
 */
static void h2_flush_queue(t_http2_connection *conn) {
	t_h2_work_item *item = conn->queue_head;
	t_h2_work_item *next;

	while (item != NULL) {
		next = item->next;
		conn->pending_requests--;
		free_work_item(item);
		item = next;
	}
	conn->queue_head = NULL;
	conn->queue_tail = NULL;
	conn->queued_count = 0;
}

/* Dispatch queued items to worker threads.  Respects H2_MAX_WORKERS
 * to prevent pthread_create() exhaustion under burst load.
 *
 * Called at 3 points per event loop iteration:
 * 1. After process_response_queue() — workers finished, slots free
 * 2. After connection I/O — new items were just enqueued by callbacks
 * 3. During graceful shutdown drain — flush remaining work
 *
 * On start_worker() failure: break (not return) so other connections
 * still get a chance.  Worker limit hit: return early, next drain
 * cycle will pick up when workers finish and decrement the counter.
 */
static void h2_drain_dispatch_queue(void) {
	t_http2_connection *conn;
	t_h2_work_item *item, *next_item;
	int i;

	for (i = 0; i < h2_loop.conn_count; i++) {
		conn = h2_loop.connections[i];

		while (conn->queue_head != NULL) {
			/* Backpressure: don't exceed worker limit */
			if (__sync_fetch_and_add(&h2_active_workers, 0) >= H2_MAX_WORKERS) {
				return;  /* at capacity — retry when workers finish */
			}

			item = conn->queue_head;
			next_item = item->next;
			__sync_fetch_and_add(&h2_active_workers, 1);
			if (h2_dispatch_to_worker(item) != 0) {
				__sync_fetch_and_sub(&h2_active_workers, 1);
				break;  /* this connection's dispatch failed — try others */
			}

			conn->queue_head = next_item;
			if (conn->queue_head == NULL) {
				conn->queue_tail = NULL;
			}
			conn->queued_count--;
		}
	}
}

/* ========================================================================
 *  nghttp2 callbacks
 * ======================================================================== */

static int on_begin_headers_callback(nghttp2_session *ng_session, const nghttp2_frame *frame,
                                     void *user_data) {
	t_http2_connection *conn = (t_http2_connection*)user_data;

	(void)ng_session;

	if ((frame->hd.type != NGHTTP2_HEADERS) || (frame->headers.cat != NGHTTP2_HCAT_REQUEST)) {
		return 0;
	}

	/* Don't accept new streams on a closing connection */
	if (conn->closing) {
		return NGHTTP2_ERR_TEMPORAL_CALLBACK_FAILURE;
	}

	if (create_stream(conn, frame->hd.stream_id) == NULL) {
		return NGHTTP2_ERR_CALLBACK_FAILURE;
	}

	return 0;
}

static int on_header_callback(nghttp2_session *ng_session, const nghttp2_frame *frame,
                              const uint8_t *name, size_t namelen,
                              const uint8_t *value, size_t valuelen,
                              uint8_t flags, void *user_data) {
	t_http2_connection *conn = (t_http2_connection*)user_data;
	t_http2_stream *stream;
	size_t old_len, new_len;
	char *new_data;

	(void)ng_session;
	(void)flags;

	if ((frame->hd.type != NGHTTP2_HEADERS) || (frame->headers.cat != NGHTTP2_HCAT_REQUEST)) {
		return 0;
	}

	if ((namelen > H2_MAX_HEADER_VALUE_LEN) || (valuelen > H2_MAX_HEADER_VALUE_LEN)) {
		return NGHTTP2_ERR_TEMPORAL_CALLBACK_FAILURE;
	}

	if ((stream = find_stream(conn, frame->hd.stream_id)) == NULL) {
		return NGHTTP2_ERR_TEMPORAL_CALLBACK_FAILURE;
	}

	/* Enforce total header list size limit (SEC-002, SEC-011) */
	stream->header_list_size += namelen + valuelen + 32;
	if (stream->header_list_size > H2_MAX_HEADER_LIST_SIZE) {
		return NGHTTP2_ERR_TEMPORAL_CALLBACK_FAILURE;
	}

	if ((namelen > 0) && (name[0] == ':')) {
		if ((namelen == 7) && (memcmp(name, ":method", 7) == 0)) {
			free(stream->method);
			if ((stream->method = strndup((const char*)value, valuelen)) == NULL) {
				return NGHTTP2_ERR_CALLBACK_FAILURE;
			}
		} else if ((namelen == 5) && (memcmp(name, ":path", 5) == 0)) {
			/* Enforce max_url_length (mirrors parse_request() → 414) */
			if ((conn->base_session->config->max_url_length > 0) &&
			    ((int)valuelen > conn->base_session->config->max_url_length)) {
				return NGHTTP2_ERR_TEMPORAL_CALLBACK_FAILURE;
			}
			free(stream->path);
			if ((stream->path = strndup((const char*)value, valuelen)) == NULL) {
				return NGHTTP2_ERR_CALLBACK_FAILURE;
			}
		} else if ((namelen == 10) && (memcmp(name, ":authority", 10) == 0)) {
			free(stream->authority);
			if ((stream->authority = strndup((const char*)value, valuelen)) == NULL) {
				return NGHTTP2_ERR_CALLBACK_FAILURE;
			}
		} else if ((namelen == 7) && (memcmp(name, ":scheme", 7) == 0)) {
			free(stream->scheme);
			if ((stream->scheme = strndup((const char*)value, valuelen)) == NULL) {
				return NGHTTP2_ERR_CALLBACK_FAILURE;
			}
		}
	} else {
		/* RFC 7540 §8.1.2.5: concatenate multiple cookie headers with "; " */
		if ((namelen == 6) && (memcmp(name, "cookie", 6) == 0)) {
			t_http_header *hdr = stream->headers;

			while (hdr != NULL) {
				if (strncasecmp(hdr->data, "cookie:", 7) == 0) {
					old_len = (size_t)hdr->length;
					new_len = old_len + 2 + valuelen;

					if ((new_data = (char*)realloc(hdr->data, new_len + 1)) == NULL) {
						return NGHTTP2_ERR_CALLBACK_FAILURE;
					}

					new_data[old_len] = ';';
					new_data[old_len + 1] = ' ';
					memcpy(new_data + old_len + 2, value, valuelen);
					new_data[new_len] = '\0';
					hdr->data = new_data;
					hdr->length = (int)new_len;

					return 0;
				}

				hdr = hdr->next;
			}
		}

		if (add_stream_header(stream, name, namelen, value, valuelen) == -1) {
			return NGHTTP2_ERR_CALLBACK_FAILURE;
		}
	}

	return 0;
}

static int on_data_chunk_recv_callback(nghttp2_session *ng_session, uint8_t flags,
                                       int32_t stream_id, const uint8_t *data, size_t len,
                                       void *user_data) {
	t_http2_connection *conn = (t_http2_connection*)user_data;
	t_http2_stream *stream;
	size_t new_capacity;
	char *new_body;

	(void)ng_session;
	(void)flags;

	if ((stream = find_stream(conn, stream_id)) == NULL) {
		return NGHTTP2_ERR_TEMPORAL_CALLBACK_FAILURE;
	}

	/* Enforce max_request_size: header block + body combined
	 * (mirrors fetch_request() max_request_size check for HTTP/1.x)
	 */
	if (conn->base_session->binding->max_request_size > 0) {
		if (stream->header_list_size + stream->body_size + len >
		    (size_t)conn->base_session->binding->max_request_size) {
			return NGHTTP2_ERR_TEMPORAL_CALLBACK_FAILURE;
		}
	}

	if (stream->body_size + len >= stream->body_capacity) {
		new_capacity = stream->body_capacity == 0 ? 4096 : stream->body_capacity;
		while (new_capacity < stream->body_size + len + 1) {
			if (new_capacity > SIZE_MAX / 2) {
				return NGHTTP2_ERR_CALLBACK_FAILURE;
			}
			new_capacity *= 2;
		}
		if ((new_body = (char*)realloc(stream->body, new_capacity)) == NULL) {
			return NGHTTP2_ERR_CALLBACK_FAILURE;
		}
		stream->body = new_body;
		stream->body_capacity = new_capacity;
	}

	memcpy(stream->body + stream->body_size, data, len);
	stream->body_size += len;

	return 0;
}

/* Frame received: enqueue completed requests for worker dispatch.
 * Previously dispatched directly via h2_dispatch_to_worker(), which
 * caused thread exhaustion under burst load.  Now enqueues; the event
 * loop drains via h2_drain_dispatch_queue().
 */
static int on_frame_recv_callback(nghttp2_session *ng_session, const nghttp2_frame *frame,
                                  void *user_data) {
	t_http2_connection *conn = (t_http2_connection*)user_data;
	t_http2_stream *stream;
	t_h2_work_item *item;

	(void)ng_session;

	switch (frame->hd.type) {
		case NGHTTP2_HEADERS:
			if (frame->headers.cat != NGHTTP2_HCAT_REQUEST) {
				break;
			}

			if ((stream = find_stream(conn, frame->hd.stream_id)) == NULL) {
				break;
			}

			stream->headers_complete = true;

			if (frame->hd.flags & NGHTTP2_FLAG_END_STREAM) {
				stream->request_complete = true;
				item = h2_clone_stream_to_work_item(conn, stream);
				if (item == NULL) {
					nghttp2_submit_rst_stream(conn->ng_session, NGHTTP2_FLAG_NONE,
					                          frame->hd.stream_id, NGHTTP2_INTERNAL_ERROR);
				} else if (h2_enqueue_request(conn, item) != 0) {
					/* queue full — backpressure */
					free_work_item(item);
					nghttp2_submit_rst_stream(conn->ng_session, NGHTTP2_FLAG_NONE,
					                          frame->hd.stream_id, NGHTTP2_INTERNAL_ERROR);
				}
			}
			break;

		case NGHTTP2_DATA:
			if ((stream = find_stream(conn, frame->hd.stream_id)) == NULL) {
				break;
			}

			if (frame->hd.flags & NGHTTP2_FLAG_END_STREAM) {
				stream->request_complete = true;
				item = h2_clone_stream_to_work_item(conn, stream);
				if (item == NULL) {
					nghttp2_submit_rst_stream(conn->ng_session, NGHTTP2_FLAG_NONE,
					                          frame->hd.stream_id, NGHTTP2_INTERNAL_ERROR);
				} else if (h2_enqueue_request(conn, item) != 0) {
					/* queue full — backpressure */
					free_work_item(item);
					nghttp2_submit_rst_stream(conn->ng_session, NGHTTP2_FLAG_NONE,
					                          frame->hd.stream_id, NGHTTP2_INTERNAL_ERROR);
				}
			}
			break;

		case NGHTTP2_GOAWAY:
			/* Client sent GOAWAY — no new streams will arrive.
			 * Mark closing so we drain pending responses and clean up,
			 * instead of holding the connection for H2_IDLE_TIMEOUT.
			 */
			h2_mark_closing(conn);
			break;

		default:
			break;
	}

	/* H2MaxRequests: initiate graceful close after serving the configured
	 * maximum number of requests on this connection.  Frees per-connection
	 * memory allocations.  Pending responses are drained before close.
	 */
	if ((conn->base_session->binding->h2_max_requests > 0) &&
	    (conn->requests_served >= conn->base_session->binding->h2_max_requests)) {
		h2_mark_closing(conn);
	}

	return 0;
}

static int on_stream_close_callback(nghttp2_session *ng_session, int32_t stream_id,
                                    uint32_t error_code, void *user_data) {
	t_http2_connection *conn = (t_http2_connection*)user_data;
	t_h2_data_source *data_src;

	(void)error_code;

	/* Free leaked data source if stream was reset before response was
	 * fully sent.  In the normal case (EOF reached), h2_data_source_read
	 * already cleared this to NULL.
	 */
	data_src = (t_h2_data_source*)nghttp2_session_get_stream_user_data(ng_session, stream_id);
	if (data_src != NULL) {
		free(data_src->response_buf);
		data_src->response_buf = NULL;
		free(data_src);
		nghttp2_session_set_stream_user_data(ng_session, stream_id, NULL);
	}

	destroy_stream(conn, stream_id);

	return 0;
}

/* nghttp2 I/O: send data to TLS socket (non-blocking).
 *
 * Calls mbedtls_ssl_write() directly (no retry loop). On WANT_WRITE
 * (socket send buffer full), returns NGHTTP2_ERR_WOULDBLOCK so nghttp2
 * pauses and the event loop waits for POLLOUT before retrying.
 *
 * mbedtls guarantees: after WANT_WRITE, the next call MUST use the
 * exact same buffer pointer and length. nghttp2 guarantees: after
 * WOULDBLOCK, send_callback is called with the same data/length.
 * These guarantees align, making non-blocking writes safe.
 */
static ssize_t h2_send_callback(nghttp2_session *ng_session, const uint8_t *data,
                                size_t length, int flags, void *user_data) {
	t_http2_connection *conn = (t_http2_connection*)user_data;
	int result;

	(void)ng_session;
	(void)flags;

	result = mbedtls_ssl_write(&(conn->base_session->tls_context),
	                           (const unsigned char*)data, length);

	if ((result == MBEDTLS_ERR_SSL_WANT_WRITE) ||
	    (result == MBEDTLS_ERR_SSL_WANT_READ)) {
		conn->write_blocked = true;
		return NGHTTP2_ERR_WOULDBLOCK;
	}

	/* Safety net: catch EAGAIN that mbedTLS may misclassify as a hard
	 * error (MBEDTLS_ERR_NET_SEND_FAILED) if the socket reverts to
	 * blocking mode or SO_SNDTIMEO is set elsewhere.
	 */
	if ((result < 0) && (errno == EAGAIN || errno == EWOULDBLOCK)) {
		conn->write_blocked = true;
		return NGHTTP2_ERR_WOULDBLOCK;
	}

	if (result < 0) {
		return NGHTTP2_ERR_CALLBACK_FAILURE;
	}

	conn->write_blocked = false;
	return (ssize_t)result;
}

/* ========================================================================
 *  Connection initialization (called from event loop add)
 * ======================================================================== */

static int h2_init_connection(t_http2_connection *conn) {
	nghttp2_session_callbacks *callbacks = NULL;
	nghttp2_settings_entry settings[2];

	if (nghttp2_session_callbacks_new(&callbacks) != 0) {
		return -1;
	}

	nghttp2_session_callbacks_set_send_callback(callbacks, h2_send_callback);
	nghttp2_session_callbacks_set_on_frame_recv_callback(callbacks, on_frame_recv_callback);
	nghttp2_session_callbacks_set_on_begin_headers_callback(callbacks, on_begin_headers_callback);
	nghttp2_session_callbacks_set_on_header_callback(callbacks, on_header_callback);
	nghttp2_session_callbacks_set_on_data_chunk_recv_callback(callbacks, on_data_chunk_recv_callback);
	nghttp2_session_callbacks_set_on_stream_close_callback(callbacks, on_stream_close_callback);

	if (nghttp2_session_server_new(&conn->ng_session, callbacks, conn) != 0) {
		nghttp2_session_callbacks_del(callbacks);
		return -1;
	}
	nghttp2_session_callbacks_del(callbacks);

	settings[0].settings_id = NGHTTP2_SETTINGS_MAX_CONCURRENT_STREAMS;
	settings[0].value = (uint32_t)conn->base_session->binding->h2_max_concurrent_streams;
	settings[1].settings_id = NGHTTP2_SETTINGS_INITIAL_WINDOW_SIZE;
	settings[1].value = 65535;

	if (nghttp2_submit_settings(conn->ng_session, NGHTTP2_FLAG_NONE, settings, 2) != 0) {
		nghttp2_session_del(conn->ng_session);
		return -1;
	}

	/* Don't send SETTINGS here — the worker thread shouldn't use
	 * the TLS context after handing off to the event loop.
	 * The event loop will send SETTINGS on the first poll cycle
	 * via process_response_queue()'s flush loop (which calls
	 * nghttp2_session_send for all connections with pending data).
	 */

	return 0;
}

/* ========================================================================
 *  Connection close and cleanup
 * ======================================================================== */

/* Mark connection as closing and release the per-IP client slot immediately.
 * This allows new connections from the same IP while we wait for
 * pending worker responses to drain.
 */
static void h2_mark_closing(t_http2_connection *conn) {
	if (conn->closing) {
		return;
	}

	conn->closing = true;
	conn->closing_since = time(NULL);

	/* Release the per-IP slot immediately so new connections aren't blocked */
	if (!conn->client_removed) {
		if (conn->base_session->config->reconnect_delay > 0) {
			mark_client_for_removal(conn->base_session,
			                        conn->base_session->config->reconnect_delay);
		} else {
			remove_client(conn->base_session);
		}
		conn->client_removed = true;
	}
}

/* Close and free a connection.  Must only be called when
 * pending_requests == 0 (all worker responses have been drained).
 */
static void h2_free_connection(t_http2_connection *conn) {
	/* Release per-IP slot if not already done by h2_mark_closing */
	if (!conn->client_removed) {
		if (conn->base_session->config->reconnect_delay > 0) {
			mark_client_for_removal(conn->base_session,
			                        conn->base_session->config->reconnect_delay);
		} else {
			remove_client(conn->base_session);
		}
	}

	free(conn->base_session);
	conn->base_session = NULL;
	free(conn);
}

/* Close the socket/TLS and release nghttp2 state, but do NOT free(conn)
 * if workers still have outstanding responses.  In that case the
 * connection is moved to the zombie list and freed later.
 */
static void h2_close_connection(t_http2_connection *conn) {
	t_http2_connection **new_arr;
	int new_cap;

	/* Send GOAWAY (best effort — socket may be dead) */
	nghttp2_submit_goaway(conn->ng_session, NGHTTP2_FLAG_NONE,
	                      nghttp2_session_get_last_proc_stream_id(conn->ng_session),
	                      NGHTTP2_NO_ERROR, NULL, 0);
	nghttp2_session_send(conn->ng_session);

	destroy_all_streams(conn);
	nghttp2_session_del(conn->ng_session);
	conn->ng_session = NULL;

	/* Flush undispatched queue items before zombie transition.
	 * No workers hold these — safe to free.  Must happen before
	 * the pending_requests check below, otherwise queued items
	 * would keep the connection alive as a zombie indefinitely.
	 */
	h2_flush_queue(conn);

	/* Close the session's socket and TLS */
	destroy_session(conn->base_session);
	close_socket(conn->base_session);

	if (conn->pending_requests > 0) {
		/* Workers still hold item->conn pointers — move to zombie list.
		 * The connection struct stays alive until all responses drain.
		 */
		if (h2_loop.zombie_count >= h2_loop.zombie_capacity) {
			if ((h2_loop.zombie_capacity != 0) && (h2_loop.zombie_capacity > INT_MAX / 2)) {
				return;
			}
			new_cap = h2_loop.zombie_capacity == 0 ? 16 : h2_loop.zombie_capacity * 2;
			new_arr = (t_http2_connection **)realloc(
				h2_loop.zombies, (size_t)new_cap * sizeof(t_http2_connection*));
			if (new_arr == NULL) {
				/* OOM — intentionally leak the connection struct rather than
				 * freeing it while workers still hold item->conn pointers.
				 * A small leak under OOM is safer than use-after-free.
				 */
				return;
			}
			h2_loop.zombies = new_arr;
			h2_loop.zombie_capacity = new_cap;
		}
		h2_loop.zombies[h2_loop.zombie_count++] = conn;
		return;
	}

	h2_free_connection(conn);
}

/* Remove connection from array (swap with last) */
static void h2_remove_connection(int index) {
	if (index < h2_loop.conn_count - 1) {
		h2_loop.connections[index] = h2_loop.connections[h2_loop.conn_count - 1];
	}
	h2_loop.conn_count--;
}

/* ========================================================================
 *  Event loop: add connection
 * ======================================================================== */

int h2_event_loop_add_connection(t_session *session) {
	t_http2_connection *conn, **new_arr;
	int new_cap;

	/* Reject new connections if at capacity */
	if (h2_loop.conn_count + h2_loop.pending_count >= H2_MAX_CONNECTIONS) {
		return -1;
	}

	if ((conn = (t_http2_connection*)calloc(1, sizeof(t_http2_connection))) == NULL) {
		return -1;
	}

	conn->base_session = session;
	conn->last_activity = time(NULL);
	conn->closing = false;
	conn->pending_requests = 0;

	/* Initialize nghttp2 and send settings */
	if (h2_init_connection(conn) != 0) {
		free(conn);
		conn = NULL;
		return -1;
	}

	/* Add to pending queue (event loop will pick it up) */
	pthread_mutex_lock(&h2_loop.conn_mutex);

	if (h2_loop.pending_count >= h2_loop.pending_capacity) {
		if ((h2_loop.pending_capacity != 0) && (h2_loop.pending_capacity > INT_MAX / 2)) {
			pthread_mutex_unlock(&h2_loop.conn_mutex);
			nghttp2_session_del(conn->ng_session);
			free(conn);
			conn = NULL;
			return -1;
		}
		new_cap = h2_loop.pending_capacity == 0 ? 16 : h2_loop.pending_capacity * 2;
		new_arr = (t_http2_connection **)realloc(
			h2_loop.pending_conns, (size_t)new_cap * sizeof(t_http2_connection*));
		if (new_arr == NULL) {
			pthread_mutex_unlock(&h2_loop.conn_mutex);
			nghttp2_session_del(conn->ng_session);
			free(conn);
			conn = NULL;
			return -1;
		}
		h2_loop.pending_conns = new_arr;
		h2_loop.pending_capacity = new_cap;
	}

	h2_loop.pending_conns[h2_loop.pending_count++] = conn;

	pthread_mutex_unlock(&h2_loop.conn_mutex);

	/* Wake up event loop */
	if (write(h2_loop.wakeup_pipe[1], "C", 1) == -1) {
		/* Best effort */
	}

	return 0;
}

/* ========================================================================
 *  Event loop: main thread
 * ======================================================================== */

static void drain_wakeup_pipe(void) {
	char buf[64];
	while (read(h2_loop.wakeup_pipe[0], buf, sizeof(buf)) > 0) {
		/* drain */
	}
}

static void process_pending_connections(void) {
	t_http2_connection **new_arr;
	int i, new_cap;

	pthread_mutex_lock(&h2_loop.conn_mutex);

	for (i = 0; i < h2_loop.pending_count; i++) {
		/* Grow connection array if needed */
		if (h2_loop.conn_count >= h2_loop.conn_capacity) {
			if ((h2_loop.conn_capacity != 0) && (h2_loop.conn_capacity > INT_MAX / 2)) {
				h2_close_connection(h2_loop.pending_conns[i]);
				continue;
			}
			new_cap = h2_loop.conn_capacity == 0 ? H2_INITIAL_CONN_CAPACITY
			                                      : h2_loop.conn_capacity * 2;
			new_arr = (t_http2_connection **)realloc(
				h2_loop.connections, (size_t)new_cap * sizeof(t_http2_connection*));
			if (new_arr == NULL) {
				/* OOM — close this connection */
				h2_close_connection(h2_loop.pending_conns[i]);
				continue;
			}
			h2_loop.connections = new_arr;
			h2_loop.conn_capacity = new_cap;
		}

		/* Set socket timeouts: SO_RCVTIMEO (200ms) for reads,
		 * SO_SNDTIMEO (4ms) for writes — see h2_set_socket_timeouts.
		 */
		h2_set_socket_timeouts(h2_loop.pending_conns[i]->base_session->client_socket);

		h2_loop.connections[h2_loop.conn_count++] = h2_loop.pending_conns[i];
	}

	h2_loop.pending_count = 0;

	pthread_mutex_unlock(&h2_loop.conn_mutex);
}

static void process_response_queue(void) {
	t_h2_response_item *item, *next;
	int i;

	pthread_mutex_lock(&h2_loop.response_mutex);
	item = h2_loop.response_head;
	h2_loop.response_head = NULL;
	h2_loop.response_tail = NULL;
	pthread_mutex_unlock(&h2_loop.response_mutex);

	while (item != NULL) {
		next = item->next;

		if (item->conn->ng_session == NULL) {
			/* Connection is a zombie (socket/TLS already closed).
			 * Discard the response — we can't send it anymore.
			 */
			item->conn->pending_requests--;
			free(item->response_buf);
			item->response_buf = NULL;
		} else {
			/* Submit response to nghttp2 — ownership of response_buf transfers */
			if (h2_submit_response_from_buf(item->conn, item->stream_id,
			                             item->response_buf, item->response_size,
			                             item->return_code) != 0) {
				/* submit failed — response_buf freed inside h2_submit_response_from_buf */
			}
			item->response_buf = NULL;  /* Ownership transferred */

			item->conn->pending_requests--;

			/* Deferred logging */
			if ((item->log_request) && (item->log_uri != NULL)) {
				log_system(h2_loop.config, "HTTP/2 %s %s %d",
				           item->request_method == GET ? "GET" :
				           item->request_method == POST ? "POST" :
				           item->request_method == HEAD ? "HEAD" : "?",
				           item->log_uri, item->return_code);
			}
		}

		free(item->log_uri);
		item->log_uri = NULL;
		free(item);
		item = next;
	}

	/* Flush all connections that may have pending response frames */
	for (i = 0; i < h2_loop.conn_count; i++) {
		if (nghttp2_session_want_write(h2_loop.connections[i]->ng_session)) {
			if (nghttp2_session_send(h2_loop.connections[i]->ng_session) != 0) {
				h2_mark_closing(h2_loop.connections[i]);
			}
		}
	}

	/* Free zombie connections whose pending_requests have drained to 0 */
	for (i = h2_loop.zombie_count - 1; i >= 0; i--) {
		if (h2_loop.zombies[i]->pending_requests <= 0) {
			h2_free_connection(h2_loop.zombies[i]);
			/* Swap with last */
			if (i < h2_loop.zombie_count - 1) {
				h2_loop.zombies[i] = h2_loop.zombies[h2_loop.zombie_count - 1];
			}
			h2_loop.zombie_count--;
		}
	}
}

static void h2_read_and_process(t_http2_connection *conn) {
	char buf[H2_READ_BUFFER_SIZE];
	int n;
	bool got_data = false;

	conn->last_activity = time(NULL);

	/* Non-blocking read: h2_tls_receive() returns 0 on WANT_READ
	 * instead of blocking the entire event loop.
	 */
	do {
		n = h2_tls_receive(&(conn->base_session->tls_context), buf, sizeof(buf));
		if (n < 0) {
			h2_mark_closing(conn);
			return;
		}
		if (n == 0) {
			/* Partial TLS record or peer close — come back later */
			break;
		}

		got_data = true;

		if (nghttp2_session_mem_recv(conn->ng_session, (uint8_t*)buf, (size_t)n) < 0) {
			h2_mark_closing(conn);
			return;
		}
	} while (tls_pending(&(conn->base_session->tls_context)) > 0);

	/* Send any pending frames (SETTINGS ACK, WINDOW_UPDATE, responses) */
	if ((got_data) || (nghttp2_session_want_write(conn->ng_session))) {
		if (nghttp2_session_send(conn->ng_session) != 0) {
			h2_mark_closing(conn);
		}
	}
}

static void h2_check_timeouts(void) {
	t_http2_connection *conn;
	time_t now;
	int i;

	now = time(NULL);

	for (i = h2_loop.conn_count - 1; i >= 0; i--) {
		conn = h2_loop.connections[i];

		/* Force quit (ban, server shutdown).
		 * Note: force_quit in session.h is not volatile. ban_ip() with
		 * kick_on_ban may set it from a worker thread. This is a benign
		 * race — worst case we detect it one poll cycle late.
		 */
		if (conn->base_session->force_quit) {
			h2_close_connection(conn);
			h2_remove_connection(i);
			continue;
		}

		/* Closing flag set by I/O error or peer disconnect */
		if (conn->closing) {
			/* Wait for pending worker responses, but not forever */
			if ((conn->pending_requests <= 0) ||
			    ((now - conn->closing_since) > H2_CLOSING_TIMEOUT)) {
				h2_close_connection(conn);
				h2_remove_connection(i);
			}
			continue;
		}

		/* Idle timeout: apply time_for_1st_request / time_for_request
		 * from the binding config (mirrors fetch_request() deadline logic
		 * for HTTP/1.x).  Falls back to H2_IDLE_TIMEOUT if both are 0.
		 */
		int idle_timeout;

		if (conn->requests_served == 0) {
			idle_timeout = conn->base_session->binding->time_for_1st_request;
		} else {
			idle_timeout = conn->base_session->binding->time_for_request;
		}

		if (idle_timeout <= 0) {
			idle_timeout = H2_IDLE_TIMEOUT;
		}

		if ((conn->pending_requests == 0) &&
		    ((now - conn->last_activity) > idle_timeout)) {
			h2_close_connection(conn);
			h2_remove_connection(i);
			continue;
		}
	}
}

static void *h2_event_loop_thread(void *arg) {
	struct pollfd *pollfds = NULL, *tmp;
	t_http2_connection *conn;
	int nfds = 0, conn_count, i, poll_result, total_pending;

	(void)arg;
	conn_count = -1;

	while (h2_loop.running) {
		/* Build pollfd array: [0] = wakeup pipe, [1..N] = connection sockets */
		if (conn_count != h2_loop.conn_count) {
			nfds = 1 + h2_loop.conn_count;
			if ((tmp = (struct pollfd*)realloc(pollfds, (size_t)nfds * sizeof(struct pollfd))) == NULL) {
				break;
			}
			pollfds = tmp;
			conn_count = h2_loop.conn_count;
		}

		pollfds[0].fd = h2_loop.wakeup_pipe[0];
		pollfds[0].events = POLLIN;
		pollfds[0].revents = 0;

		for (i = 0; i < h2_loop.conn_count; i++) {
			pollfds[i + 1].fd = h2_loop.connections[i]->base_session->client_socket;
			pollfds[i + 1].events = POLLIN;
			/* Add POLLOUT when writes are blocked or nghttp2 has queued data */
			if ((h2_loop.connections[i]->write_blocked) ||
					((h2_loop.connections[i]->ng_session != NULL) &&
					nghttp2_session_want_write(h2_loop.connections[i]->ng_session))) {
				pollfds[i + 1].events |= POLLOUT;
			}
			pollfds[i + 1].revents = 0;
		}

		/* poll() — 1000ms timeout for idle checks */
		if ((poll_result = poll(pollfds, (nfds_t)nfds, 1000)) < 0) {
			if (errno == EINTR) {
				continue;
			}
			break;
		}

		/* Wakeup pipe: new connections queued */
		if ((poll_result > 0) && (pollfds[0].revents & POLLIN)) {
			drain_wakeup_pipe();
			process_pending_connections();
		}

		/* Process worker responses every cycle (wakeup byte may be
		 * coalesced or lost if pipe was already readable).
		 */
		process_response_queue();
		h2_drain_dispatch_queue();  /* drain point 1: workers finished, slots free */

		/* Connection I/O — iterate only connections that were in the
		 * pollfds array (nfds - 1).  process_pending_connections() may
		 * have added new connections after poll(), increasing conn_count
		 * beyond the pollfds size.  Accessing pollfds[i+1] for those
		 * new connections would read uninitialized heap memory, whose
		 * garbage revents bits (e.g. POLLHUP) would spuriously kill
		 * perfectly good connections.
		 */
		if (poll_result > 0) {
			for (i = 0; i < nfds - 1; i++) {
				if (pollfds[i + 1].revents == 0) {
					continue;
				}

				/* POLLIN: data available — read and process */
				if (pollfds[i + 1].revents & POLLIN) {
					h2_read_and_process(h2_loop.connections[i]);
				}

				/* POLLOUT: socket ready for writing — flush pending data */
				if (pollfds[i + 1].revents & POLLOUT) {
					conn = h2_loop.connections[i];
					conn->write_blocked = false;
					if ((conn->ng_session != NULL) &&
					    (nghttp2_session_want_write(conn->ng_session))) {
						if (nghttp2_session_send(conn->ng_session) != 0) {
							h2_mark_closing(conn);
						}
					}
				}

				/* POLLHUP/POLLERR: peer disconnect or error */
				if (pollfds[i + 1].revents & (POLLHUP | POLLERR)) {
					h2_mark_closing(h2_loop.connections[i]);
				}
			}
		}

		h2_drain_dispatch_queue();  /* drain point 2: new items enqueued by I/O above */

		/* Idle timeout + force_quit + closing cleanup */
		h2_check_timeouts();

		/* Graceful drain: send GOAWAY, wait for pending requests */
		if (h2_loop.draining) {
			if (!h2_loop.goaway_sent) {
				for (i = 0; i < h2_loop.conn_count; i++) {
					conn = h2_loop.connections[i];
					if ((conn->ng_session != NULL) && (!conn->closing)) {
						nghttp2_submit_goaway(conn->ng_session,
						    NGHTTP2_FLAG_NONE,
						    nghttp2_session_get_last_proc_stream_id(conn->ng_session),
						    NGHTTP2_NO_ERROR, NULL, 0);
						nghttp2_session_send(conn->ng_session);
						h2_mark_closing(conn);
					}
				}
				h2_loop.goaway_sent = true;
			}

			/* Process worker responses that arrived during drain */
			process_response_queue();
			h2_drain_dispatch_queue();  /* drain point 3: graceful shutdown */

			/* Count remaining pending requests */
			total_pending = 0;
			for (i = 0; i < h2_loop.conn_count; i++) {
				total_pending += h2_loop.connections[i]->pending_requests;
			}
			for (i = 0; i < h2_loop.zombie_count; i++) {
				total_pending += h2_loop.zombies[i]->pending_requests;
			}

			if ((total_pending == 0) || (time(NULL) >= h2_loop.drain_deadline)) {
				h2_loop.running = false;
			}
		}
	}

	if (pollfds != NULL) {
		free(pollfds);
	}

	/* Shutdown: close all remaining connections */
	while (h2_loop.conn_count > 0) {
		h2_close_connection(h2_loop.connections[0]);
		h2_remove_connection(0);
	}

	return NULL;
}

/* ========================================================================
 *  Event loop init / shutdown
 * ======================================================================== */

int h2_event_loop_init(t_config *config) {
	pthread_attr_t attr;
	int flags;

	memset(&h2_loop, 0, sizeof(h2_loop));
	h2_loop.config = config;
	h2_loop.running = true;
	h2_loop.wakeup_pipe[0] = -1;
	h2_loop.wakeup_pipe[1] = -1;

	if (pipe(h2_loop.wakeup_pipe) != 0) {
		return -1;
	}

	/* Make both ends non-blocking.
	 * Read end: for drain_wakeup_pipe().
	 * Write end: if the pipe buffer is full (all workers writing at once),
	 * write() returns EAGAIN instead of blocking the worker thread.
	 * EAGAIN is safe to ignore: the pipe already has data, so the event
	 * loop will wake up regardless.
	 */
	flags = fcntl(h2_loop.wakeup_pipe[0], F_GETFL, 0);
	if (flags != -1) {
		fcntl(h2_loop.wakeup_pipe[0], F_SETFL, flags | O_NONBLOCK);
	}
	flags = fcntl(h2_loop.wakeup_pipe[1], F_GETFL, 0);
	if (flags != -1) {
		fcntl(h2_loop.wakeup_pipe[1], F_SETFL, flags | O_NONBLOCK);
	}

	if (pthread_mutex_init(&h2_loop.conn_mutex, NULL) != 0) {
		close(h2_loop.wakeup_pipe[0]);
		close(h2_loop.wakeup_pipe[1]);
		return -1;
	}

	if (pthread_mutex_init(&h2_loop.response_mutex, NULL) != 0) {
		pthread_mutex_destroy(&h2_loop.conn_mutex);
		close(h2_loop.wakeup_pipe[0]);
		close(h2_loop.wakeup_pipe[1]);
		return -1;
	}

	if (pthread_attr_init(&attr) != 0) {
		pthread_mutex_destroy(&h2_loop.response_mutex);
		pthread_mutex_destroy(&h2_loop.conn_mutex);
		close(h2_loop.wakeup_pipe[0]);
		close(h2_loop.wakeup_pipe[1]);
		return -1;
	}

	if (pthread_create(&h2_loop.thread, &attr, h2_event_loop_thread, NULL) != 0) {
		pthread_attr_destroy(&attr);
		pthread_mutex_destroy(&h2_loop.response_mutex);
		pthread_mutex_destroy(&h2_loop.conn_mutex);
		close(h2_loop.wakeup_pipe[0]);
		close(h2_loop.wakeup_pipe[1]);
		return -1;
	}

	pthread_attr_destroy(&attr);

	return 0;
}

void h2_event_loop_shutdown(void) {
	t_h2_response_item *item, *next;
	int i, total_pending;
	time_t deadline;

	if ((!h2_loop.running) || (h2_loop.shutdown_done)) {
		return;
	}
	h2_loop.shutdown_done = true;

	/* 1. Signal: tell event loop to stop accepting new work */
	h2_loop.running = false;

	/* Wake up event loop so it exits */
	if (h2_loop.wakeup_pipe[1] != -1) {
		if (write(h2_loop.wakeup_pipe[1], "Q", 1) == -1) {
			/* Best effort */
		}
	}

	pthread_join(h2_loop.thread, NULL);

	/* 2. Drain: wait for pending requests to finish before destroying
	 * synchronization primitives.  Workers may still hold locks;
	 * destroying mutexes while locked is undefined behavior.
	 * 10s timeout: allows slow CGI/reverse-proxy workers to finish
	 * while still bounding shutdown time.
	 */
	deadline = time(NULL) + 10;
	for (;;) {
		/* Drain completed responses so pending_requests decrements */
		pthread_mutex_lock(&h2_loop.response_mutex);
		item = h2_loop.response_head;
		h2_loop.response_head = NULL;
		h2_loop.response_tail = NULL;
		pthread_mutex_unlock(&h2_loop.response_mutex);

		while (item != NULL) {
			next = item->next;
			item->conn->pending_requests--;
			free(item->response_buf);
			item->response_buf = NULL;
			free(item->log_uri);
			item->log_uri = NULL;
			free(item);
			item = next;
		}

		/* Count remaining pending requests across all connections */
		total_pending = 0;
		for (i = 0; i < h2_loop.conn_count; i++) {
			total_pending += h2_loop.connections[i]->pending_requests;
		}
		for (i = 0; i < h2_loop.zombie_count; i++) {
			total_pending += h2_loop.zombies[i]->pending_requests;
		}

		if ((total_pending == 0) || (time(NULL) >= deadline)) {
			break;
		}

		usleep(50 * 1000);  /* 50ms between drain attempts */
	}

	/* 3. Wait for all workers to finish before destroying mutexes.
	 * Workers call pthread_mutex_lock(&response_mutex) to enqueue
	 * responses — destroying it while locked is undefined behavior.
	 */
	deadline = time(NULL) + 5;
	while (__sync_fetch_and_add(&h2_active_workers, 0) > 0) {
		if (time(NULL) >= deadline) {
			break;
		}
		usleep(10 * 1000);
	}

	pthread_mutex_destroy(&h2_loop.conn_mutex);
	pthread_mutex_destroy(&h2_loop.response_mutex);

	/* 4. Free all connections and memory */
	for (i = 0; i < h2_loop.zombie_count; i++) {
		h2_free_connection(h2_loop.zombies[i]);
	}

	free(h2_loop.connections);
	h2_loop.connections = NULL;
	free(h2_loop.pending_conns);
	h2_loop.pending_conns = NULL;
	free(h2_loop.zombies);
	h2_loop.zombies = NULL;

	/* 5. Close pipes last — workers that outlived the drain timeout
	 * may still write() to the pipe.  Keeping fds open until here
	 * prevents fd reuse (where a stale write corrupts an unrelated fd).
	 * The write() simply returns EBADF/EPIPE after this point.
	 */
	if (h2_loop.wakeup_pipe[0] != -1) {
		close(h2_loop.wakeup_pipe[0]);
	}
	if (h2_loop.wakeup_pipe[1] != -1) {
		close(h2_loop.wakeup_pipe[1]);
	}
}

void h2_graceful_shutdown(int timeout_seconds) {
	t_h2_response_item *item, *next;
	int i;
	time_t worker_deadline;

	if ((!h2_loop.running) || (h2_loop.shutdown_done)) {
		return;
	}
	h2_loop.shutdown_done = true;

	/* Tell the event loop to start draining.
	 * Write order matters: drain_deadline before draining, so the event
	 * loop never reads draining==true with a stale drain_deadline.
	 */
	h2_loop.drain_deadline = time(NULL) + timeout_seconds;
	h2_loop.goaway_sent = false;
	h2_loop.draining = true;

	/* Wake event loop so it processes the drain flag immediately */
	if (h2_loop.wakeup_pipe[1] != -1) {
		if (write(h2_loop.wakeup_pipe[1], "G", 1) == -1) {
			/* Best effort */
		}
	}

	/* Wait for event loop thread to finish draining and exit */
	pthread_join(h2_loop.thread, NULL);
	h2_loop.running = false;

	/* Same cleanup as h2_event_loop_shutdown */
	for (i = 0; i < h2_loop.zombie_count; i++) {
		h2_free_connection(h2_loop.zombies[i]);
	}

	if (h2_loop.connections != NULL) {
		free(h2_loop.connections);
		h2_loop.connections = NULL;
	}

	if (h2_loop.pending_conns != NULL) {
		free(h2_loop.pending_conns);
		h2_loop.pending_conns = NULL;
	}

	if (h2_loop.zombies != NULL) {
		free(h2_loop.zombies);
		h2_loop.zombies = NULL;
	}

	/* Wait for workers to finish before closing pipes or destroying
	 * mutexes.  Workers may still write() to wakeup_pipe[1] and
	 * lock response_mutex — closing/destroying while held is UB.
	 */
	worker_deadline = time(NULL) + 5;
	while (__sync_fetch_and_add(&h2_active_workers, 0) > 0) {
		if (time(NULL) >= worker_deadline) {
			break;
		}
		usleep(10 * 1000);
	}

	if (h2_loop.wakeup_pipe[0] != -1) {
		close(h2_loop.wakeup_pipe[0]);
	}
	if (h2_loop.wakeup_pipe[1] != -1) {
		close(h2_loop.wakeup_pipe[1]);
	}

	pthread_mutex_destroy(&h2_loop.conn_mutex);
	pthread_mutex_destroy(&h2_loop.response_mutex);

	item = h2_loop.response_head;
	while (item != NULL) {
		next = item->next;
		free(item->response_buf);
		item->response_buf = NULL;
		free(item->log_uri);
		item->log_uri = NULL;
		free(item);
		item = next;
	}
}

#endif /* ENABLE_HTTP2 */
