G1 step 1: gemini get, and two TLS connections measured

`gemini get <url>` 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
This commit is contained in:
2026-10-05 00:27:05 +02:00
co-authored by Claude Opus 5.5
parent dc9b0e14ce
commit 2d384f5cff
4 changed files with 137 additions and 1 deletions
+7 -1
View File
@@ -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 <url>` 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.
+5
View File
@@ -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 <name|char> press a key: up down left right select back home del tab, or one character\n"
"wifi status | wifi add <ssid><TAB><password>\n"
"gemini get <url> fetch a Gemini page and report header, size, certificate, heap\n"
"irc start | irc stop | irc dump | irc say <buffer> <text>\n"
"ls [folder] | rm <path> | install <path.ota> (Update from SD)\n"
"sd list | cat <path> | log <text> | 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 <buffer index> <text>
+104
View File
@@ -0,0 +1,104 @@
#include "services/gemini_service.h"
#include <NetworkClientSecure.h>
#include <esp_heap_caps.h>
#include <algorithm>
#include <memory>
#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<ConsoleFetch> job(static_cast<ConsoleFetch*>(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<size_t>(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<char>(c);
} else {
body++;
if (sample.size() < 400) sample += static_cast<char>(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
+21
View File
@@ -0,0 +1,21 @@
#pragma once
#include <string>
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 <url>`: 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