perf(sync): reuse the TLS session and skip the pull's needless fetch

Two halves of the connection-cost lever (first on-device pull: 9.7s
fetch to learn 'up to date'; a rejected-push :sync runs three full
handshakes):

- esp_mbedtls_stream.c caches the mbedTLS session at stream close and
  offers it on the next connect to the same host — the server then
  skips the certificate exchange and most of the key exchange. Second
  vendor delta, single git thread, best-effort (any refusal falls back
  to a full handshake).

- pull_once now ls-refs first: the ref advertisement alone answers
  'anything new?', so up-to-date and local-ahead return without ever
  entering pack negotiation, and a needed download rides the SAME open
  connection instead of a second handshake. Tracking-ref updates share
  one update_tracking helper with the reconcile fetch.
This commit is contained in:
Julien Calixte
2026-07-14 09:21:58 +02:00
parent 75166f74cf
commit 44c65e9ea5
2 changed files with 116 additions and 19 deletions

View File

@@ -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;

View File

@@ -743,14 +743,22 @@ fn fetch_origin(repo: &Repository, branch: &str) -> Result<Oid> {
.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<PullOutcome> {
.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)")?