From 2d384f5cff90221aeb4745dd41dd9cf36ec08053 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Martin?= Date: Mon, 5 Oct 2026 00:27:05 +0200 Subject: [PATCH] G1 step 1: gemini get, and two TLS connections measured `gemini get ` fetches on a short-lived task (a handshake would trip the main loop's watchdog; an idle client should cost no stack) and reports the header, size, certificate fingerprint and heap. First page: geminiprotocol.net, 20 text/gemini, 1,184 bytes in 0.7-1.1 s. With IRC connected over TLS, a fetch dips to about 24 KB free during its handshake, about 36-40 KB after it; nothing leaks. Antenna is down, so the default aggregator becomes Cosmos. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT --- docs/milestones/G1.md | 8 ++- src/main.cpp | 5 ++ src/services/gemini_service.cpp | 104 ++++++++++++++++++++++++++++++++ src/services/gemini_service.h | 21 +++++++ 4 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 src/services/gemini_service.cpp create mode 100644 src/services/gemini_service.h diff --git a/docs/milestones/G1.md b/docs/milestones/G1.md index 78ed937..32e087f 100644 --- a/docs/milestones/G1.md +++ b/docs/milestones/G1.md @@ -16,7 +16,7 @@ Gemini (geminiprotocol.net): one request per TLS connection on port 1965, the re | Q75 | Gemtext rendering: text wrapped to the 40-column screen; `#`/`##`/`###` headings in bold and accent; `*` lists with bullets; `>` quotes indented and muted; preformatted blocks unwrapped, Left/Right to scroll; `=>` links with their label, numbered. | | Q76 | Up/Down scroll; Tab and Shift+Tab move between links; Enter follows; Backspace goes back; `g` opens the address line. Links to other protocols show their URL and aren't followed. | | Q77 | Back history of 20 URLs in RAM, with scroll positions; going back refetches (or reopens a Saved Page). **Bookmarks** in `/gemini/bookmarks.gmi` (a gemtext page, shown on the start page); `b` adds the current page. Without a card, a built-in start page. | -| Q78 | Start page: bookmarks, then Saved Pages, then defaults: geminiprotocol.net, a search engine (kennedy.gemi.dev), an aggregator (Antenna). | +| Q78 | Start page: bookmarks, then Saved Pages, then defaults: geminiprotocol.net, a search engine (kennedy.gemi.dev), an aggregator (Cosmos; Antenna was down when measured). | | Q79 | UTF-8 decoded; characters outside the Latin-1 fonts shown as `?`. | | Q80 | IRC and Gemini can run together: each fetch opens one connection, reads and closes it. If there isn't memory for a second TLS connection, the fetch fails with a clear message and IRC is untouched. Measured in step 1. | | Q81 | Debug aid: `gemini get ` prints the status, MIME type, size, certificate fingerprint and the first lines. URL resolution (RFC 3986), the response header and gemtext parsing are host-tested. | @@ -25,6 +25,12 @@ Gemini (geminiprotocol.net): one request per TLS connection on port 1965, the re | Q84 | The start page lists Saved Pages, newest first, grouped by capsule; they open with no network. In a Saved Page, a link to another Saved Page opens the saved copy; other links fetch online if Wi-Fi is up, or say "not saved, offline". A Saved Page shows when it was saved; `r` refreshes it. | | Q85 | Saved Pages are deleted from the App only (`d`, with confirmation), never by Storage Clean-up's age rules, like Notes. | +## Measured (step 1) + +- `gemini://geminiprotocol.net/`: `20 text/gemini`, 1,184 bytes, TLS handshake 0.7–1.1 s, whole fetch 0.7–1.1 s; kennedy.gemi.dev 1.9 s. The fetch task's stack peaks at about 3.6 KB of 6. +- **Heap, Debug Build, IRC connected over TLS:** about 68 KB free before a fetch. After the handshake the fetch holds about 32 KB (36–40 KB left); the handshake itself (certificate chain parsed with the 16 KB receive buffer allocated) dips to about **24 KB** for a second or two. Nothing leaks: the heap after matches the heap before. +- Antenna (`warmedal.se`) doesn't answer, from the PC either; the default aggregator becomes Cosmos (`gemini://skyjake.fi/~Cosmos/`, which redirects to `cosmos.skyjake.fi`). + ## Done when - `gemini get gemini://geminiprotocol.net/` prints the header, size and fingerprint on the console. diff --git a/src/main.cpp b/src/main.cpp index 9d0324f..6e8b493 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -27,6 +27,7 @@ #include "services/irc_service.h" #include "services/clock_service.h" #include "services/debug_console.h" +#include "services/gemini_service.h" #include "services/gnss_service.h" #include "services/power_service.h" #include "services/storage_service.h" @@ -54,6 +55,7 @@ static StorageService* storageService; static PowerService* power; static ClockService* clockService; static GnssService* gnssService; +static GeminiService gemini; static SavedNetworks* savedNetworks; static WifiService* wifi; static IrcService* irc; @@ -347,6 +349,7 @@ static const char* const kHelp = "coredump erase forget the core dump in flash\n" "key press a key: up down left right select back home del tab, or one character\n" "wifi status | wifi add \n" + "gemini get fetch a Gemini page and report header, size, certificate, heap\n" "irc start | irc stop | irc dump | irc say \n" "ls [folder] | rm | install (Update from SD)\n" "sd list | cat | log | burst | sound on|off | short | normal\n" @@ -462,6 +465,8 @@ static void runCommand(String line) { storageService->requestListing(); listingWanted = true; } + if (line.startsWith("gemini get ")) + if (!gemini.fetchToConsole(line.substring(11).c_str())) console.println("gemini: a fetch is already running"); if (line == "irc start") irc->connect(); if (line == "irc stop") irc->disconnect(); if (line.startsWith("irc say ")) { // irc say diff --git a/src/services/gemini_service.cpp b/src/services/gemini_service.cpp new file mode 100644 index 0000000..d6576c7 --- /dev/null +++ b/src/services/gemini_service.cpp @@ -0,0 +1,104 @@ +#include "services/gemini_service.h" + +#include +#include + +#include +#include + +#include "platform/console.h" + +namespace roro { + +namespace { + +struct ConsoleFetch { + GeminiService* service; + std::string url; + volatile bool* busy; +}; + +std::string hex(const uint8_t* data, size_t len) { + static const char* digits = "0123456789abcdef"; + std::string out; + for (size_t i = 0; i < len; i++) { + out += digits[data[i] >> 4]; + out += digits[data[i] & 15]; + } + return out; +} + +} // namespace + +bool GeminiService::fetchToConsole(const std::string& url) { + if (busy_) return false; + busy_ = true; + auto* job = new ConsoleFetch{this, url, &busy_}; + if (xTaskCreate(consoleTask, "gemini", 6144, job, 1, nullptr) != pdPASS) { + delete job; + busy_ = false; + return false; + } + return true; +} + +void GeminiService::consoleTask(void* arg) { + std::unique_ptr job(static_cast(arg)); + // Until step 2's URL parser: gemini://host[:port]/path + std::string rest = job->url.substr(job->url.find("://") == std::string::npos ? 0 : job->url.find("://") + 3); + std::string authority = rest.substr(0, rest.find('/')); + std::string host = authority.substr(0, authority.find(':')); + uint16_t port = authority.find(':') == std::string::npos ? 1965 : atoi(authority.substr(authority.find(':') + 1).c_str()); + + size_t before = esp_get_free_heap_size(), lowest = before; + auto track = [&lowest]() { lowest = std::min(lowest, esp_get_free_heap_size()); }; + uint32_t start = millis(); + { + NetworkClientSecure tls; + tls.setInsecure(); // trust on first use: the fingerprint is what counts (Q71) + tls.setTimeout(15); + if (!tls.connect(host.c_str(), port)) { + console.printf("gemini: cannot connect to %s:%u\n", host.c_str(), port); + } else { + uint32_t connected = millis(); + track(); // the handshake's buffers are allocated now + uint8_t sha[32]; + std::string fingerprint = tls.getFingerprintSHA256(sha) ? hex(sha, 32) : "?"; + tls.print((job->url + "\r\n").c_str()); + std::string header, sample; + size_t body = 0; + bool inBody = false; + uint32_t last = millis(); + while (tls.connected() || tls.available()) { + int c = tls.read(); + if (c < 0) { + if (millis() - last > 15000) break; + delay(5); + continue; + } + last = millis(); + if ((body & 255) == 0) track(); + if (!inBody) { + if (c == '\n') inBody = true; + else if (c != '\r' && header.size() < 1100) header += static_cast(c); + } else { + body++; + if (sample.size() < 400) sample += static_cast(c); + } + } + console.printf("gemini: %s\ngemini: header '%s', %u bytes, TLS %lu ms, total %lu ms\n", job->url.c_str(), + header.c_str(), (unsigned)body, (unsigned long)(connected - start), + (unsigned long)(millis() - start)); + console.printf("gemini: certificate sha256 %s\n", fingerprint.c_str()); + console.printf("--- first bytes ---\n%s\n--- end ---\n", sample.c_str()); + } + tls.stop(); + } + console.printf("gemini: heap %u before, %u lowest during, %u after; stack left %u\n", (unsigned)before, + (unsigned)lowest, (unsigned)esp_get_free_heap_size(), (unsigned)uxTaskGetStackHighWaterMark(nullptr)); + *job->busy = false; + job.reset(); + vTaskDelete(nullptr); +} + +} // namespace roro diff --git a/src/services/gemini_service.h b/src/services/gemini_service.h new file mode 100644 index 0000000..6a47244 --- /dev/null +++ b/src/services/gemini_service.h @@ -0,0 +1,21 @@ +#pragma once + +#include + +namespace roro { + +// Gemini (docs/milestones/G1.md). Each fetch runs on a short-lived task of its own: a TLS +// handshake can block for seconds, which the main loop's watchdog wouldn't allow, and an idle +// client should cost no stack. +class GeminiService { + public: + // `gemini get `: fetches and prints the header, size, certificate fingerprint, the first + // lines and the heap around the fetch to the console. False if a fetch is already running. + bool fetchToConsole(const std::string& url); + + private: + static void consoleTask(void* arg); + volatile bool busy_ = false; +}; + +} // namespace roro