diff --git a/firmware/components/libgit2/esp_mbedtls_stream.c b/firmware/components/libgit2/esp_mbedtls_stream.c index 7e52c80..3fe58fa 100644 --- a/firmware/components/libgit2/esp_mbedtls_stream.c +++ b/firmware/components/libgit2/esp_mbedtls_stream.c @@ -22,8 +22,19 @@ * stays with the caller, consistently — and it frees the git__malloc'd * st->ssl struct the vendored path leaked. * + * SECOND DELTA (2026-07-14): TLS session resumption. Every git operation + * (push, fetch, ls-refs — and a rejected push's reconcile+retry runs three) + * opens a fresh HTTPS connection, and a full handshake on this 160 MHz core + * costs seconds each time. libgit2 gives the stream no connection reuse, but + * mbedTLS can resume: cache the session after a successful handshake + * (mbedtls_ssl_get_session at stream close) and offer it on the next connect + * to the same host (mbedtls_ssl_set_session before the handshake) — the + * server then skips the certificate exchange and most of the key exchange. + * Single git thread, so plain statics like the rest of this file. Best + * effort: any failure just falls back to a full handshake. + * * Keep this file in lockstep with the vendored one on submodule bumps (diff - * against it; the delta must stay this one hunk). + * against it; the deltas must stay these two hunks). */ #include "streams/mbedtls.h" @@ -73,12 +84,24 @@ static mbedtls_entropy_context mbedtls_entropy; static bool has_ca_chain = false; static mbedtls_x509_crt mbedtls_ca_chain; +/* TLS session cache for resumption (see SECOND DELTA in the header). + * Valid only on the git thread; keyed by host so a config change can't + * resume against the wrong server. */ +static bool session_valid = false; +static char session_host[64]; +static mbedtls_ssl_session saved_session; + /** * This function aims to clean-up the SSL context which * we allocated. */ static void shutdown_ssl(void) { + if (session_valid) { + mbedtls_ssl_session_free(&saved_session); + session_valid = false; + } + if (has_ca_chain) { mbedtls_x509_crt_free(&mbedtls_ca_chain); has_ca_chain = false; @@ -270,12 +293,40 @@ static int mbedtls_connect(git_stream *stream) mbedtls_ssl_set_bio(st->ssl, st->io, bio_write, bio_read, NULL); + /* Offer the cached session for an abbreviated handshake (best effort — + * a refusal by either side just runs the full handshake). */ + if (session_valid && st->host && strcmp(session_host, st->host) == 0) + (void)mbedtls_ssl_set_session(st->ssl, &saved_session); + if ((ret = mbedtls_ssl_handshake(st->ssl)) != 0) return ssl_set_error(st->ssl, ret); return verify_server_cert(st->ssl); } +/* Snapshot the (possibly ticket-refreshed) session for the next connect. + * Called at stream close — by then any TLS 1.3 NewSessionTicket sent after + * the handshake has been processed by the reads. */ +static void save_session(mbedtls_stream *st) +{ + mbedtls_ssl_session fresh; + + if (!st->connected || !st->host || strlen(st->host) >= sizeof(session_host)) + return; + + mbedtls_ssl_session_init(&fresh); + if (mbedtls_ssl_get_session(st->ssl, &fresh) != 0) { + mbedtls_ssl_session_free(&fresh); + return; + } + + if (session_valid) + mbedtls_ssl_session_free(&saved_session); + saved_session = fresh; /* shallow move — ownership transfers */ + session_valid = true; + strcpy(session_host, st->host); +} + static int mbedtls_certificate(git_cert **out, git_stream *stream) { unsigned char *encoded_cert; @@ -349,6 +400,8 @@ static int mbedtls_stream_close(git_stream *stream) mbedtls_stream *st = (mbedtls_stream *) stream; int ret = 0; + save_session(st); + if (st->connected && (ret = ssl_teardown(st->ssl)) != 0) return -1; diff --git a/firmware/src/git_sync.rs b/firmware/src/git_sync.rs index 9e6df67..ed79a8d 100644 --- a/firmware/src/git_sync.rs +++ b/firmware/src/git_sync.rs @@ -743,14 +743,22 @@ fn fetch_origin(repo: &Repository, branch: &str) -> Result { .peel_to_commit() .context("FETCH_HEAD is not a commit")? .id(); + update_tracking(repo, branch, theirs)?; + Ok(theirs) +} + +/// Point the remote-tracking ref at `tip` (which must already be in the local +/// odb). Keeps [`tracking_tip`] — and with it publish's radio-free up-to-date +/// check — honest about what origin has. +fn update_tracking(repo: &Repository, branch: &str, tip: Oid) -> Result<()> { repo.reference( &format!("refs/remotes/origin/{branch}"), - theirs, + tip, true, "typoena fetch", ) .context("updating remote-tracking ref")?; - Ok(theirs) + Ok(()) } /// Open `/sd/repo`, fetch origin, and **fast-forward only** — never a merge. @@ -781,33 +789,69 @@ fn pull_once() -> Result { .shorthand() .context("HEAD has no branch shorthand")? .to_string(); - - let t_fetch = Instant::now(); - let theirs = fetch_origin(&repo, &branch)?; - let fetch_ms = t_fetch.elapsed().as_millis(); - log::info!( - "pull: fetched origin @ {} in {fetch_ms}ms — free heap {} ({} internal)", - short(theirs), - free_heap(), - internal_free_heap() - ); - let head = repo.head()?.peel_to_commit()?.id(); + + // ls-refs first, download only if needed: the ref advertisement alone + // answers "anything new?", so the common shapes (up to date, local ahead) + // never enter pack negotiation — the first on-device pull paid a 9.7 s + // fetch just to learn it was up to date. When a download IS needed it + // rides the same open connection (no second TLS handshake). + let mut remote = repo.find_remote("origin")?; + let t_ls = Instant::now(); + remote + .connect_auth(git2::Direction::Fetch, Some(auth_callbacks()), None) + .context("connecting to origin")?; + let refname = format!("refs/heads/{branch}"); + let theirs = remote + .list() + .context("listing origin refs")? + .iter() + .find(|h| h.name() == refname) + .map(|h| h.oid()) + .with_context(|| format!("origin does not advertise {refname}"))?; + let ls_ms = t_ls.elapsed().as_millis(); + if theirs == head { - log::info!("pull: origin @ {} == HEAD — up to date (fetch {fetch_ms}ms)", short(head)); + let _ = remote.disconnect(); + update_tracking(&repo, &branch, theirs)?; + log::info!("pull: origin @ {} == HEAD — up to date (ls-refs {ls_ms}ms, no fetch)", short(head)); return Ok(PullOutcome::UpToDate); } - if repo - .graph_descendant_of(head, theirs) - .context("descendant check (local ahead)")? + // `theirs` an ancestor of HEAD ⇒ it is already in the local odb (all + // ancestors of a local commit are local) — no download needed either. + if repo.odb()?.exists(theirs) + && repo + .graph_descendant_of(head, theirs) + .context("descendant check (local ahead)")? { + let _ = remote.disconnect(); + update_tracking(&repo, &branch, theirs)?; log::info!( - "pull: HEAD {} is ahead of origin {} — nothing to pull, :sync publishes it", + "pull: HEAD {} is ahead of origin {} — nothing to pull, :sync publishes it (ls-refs {ls_ms}ms, no fetch)", short(head), short(theirs) ); return Ok(PullOutcome::LocalAhead); } + + // Origin has commits we lack: download them over the already-open + // connection (callbacks were bound at connect_auth). + let t_fetch = Instant::now(); + let mut fo = FetchOptions::new(); + fo.remote_callbacks(auth_callbacks()); + remote + .download(&[branch.as_str()], Some(&mut fo)) + .context("downloading from origin")?; + let _ = remote.disconnect(); + update_tracking(&repo, &branch, theirs)?; + let fetch_ms = t_fetch.elapsed().as_millis(); + log::info!( + "pull: downloaded origin @ {} — ls-refs {ls_ms}ms, download {fetch_ms}ms, free heap {} ({} internal)", + short(theirs), + free_heap(), + internal_free_heap() + ); + if !repo .graph_descendant_of(theirs, head) .context("descendant check (fast-forward)")?