Skip to content

feat(contrib.tinyweb): serve over TLS with with_tls - #1900

Merged
paul-hammant merged 4 commits into
mainfrom
feat/tinyweb-tls
Sep 4, 2026
Merged

feat(contrib.tinyweb): serve over TLS with with_tls#1900
paul-hammant merged 4 commits into
mainfrom
feat/tinyweb-tls

Conversation

@paul-hammant

Copy link
Copy Markdown
Collaborator

tinyweb wraps std.http, whose server has had server_set_tls all along — tinyweb simply never surfaced it. So every tinyweb app was plaintext-only for no deeper reason than a missing setter.

server = tinyweb.web_server_host("0.0.0.0", 443) {
    tinyweb.end_point(tinyweb.GET, "/") |req: ptr, res: ptr, ctx: ptr| {
        tinyweb.response_write(res, "hello over https")
    }
}
_ = tinyweb.with_tls(server, "/etc/ssl/cert.pem", "/etc/ssl/key.pem")
err = tinyweb.tw_start(server)

with_tls records the PEM pair on the existing config map; tw_start applies it after bind and before registering routes, so a bad pair fails start-up rather than after the server looks ready.

The design decision worth reviewing

A bad or half-configured pair is a start-up error, not a silent skip.

with_tls(srv, cert, "")     -> "with_tls needs BOTH a cert and a key"
with_tls(srv, missing, key) -> "TLS setup failed: failed to load TLS certificate (...)"

A server that quietly downgrades to plaintext on a port the caller believes is encrypted is worse than one that refuses to start. That's the same failure shape as the response_accept_tunnel TLS gap found in #1899 — where a silent plaintext fallback had already shipped downstream without anyone noticing, because the failure was a runtime string no test looked at.

Testing

New suite asserts three things:

  1. a real TLS handshake serving the route body (verified in the curl trace, not just a 200);
  2. plain http:// to the TLS port does not return the body;
  3. a half-configured pair refuses to start.

It's also the first tests/integration/tinyweb_* suite, so contrib/tinyweb gains CI coverage it didn't have.

build tinyweb_tls existing tinyweb tests
Linux PASS 3/3 OK
macOS 15.7.7 PASS
Windows MSYS2 SKIP-WIN (by design) module still compiles

make test 409/409; fmt gate and check-docs green.

A note on the diff

It is deliberately additive-only — 35 insertions, 0 deletions in module.ae.

ae fmt contrib reformats 23 unrelated contrib modules and most of tinyweb's own examples (collapsing aligned const blocks, exports (exports(). That noise is reverted here so the change is reviewable; the tree stays fmt-canonical either way, as the passing gate shows. Worth knowing that a contrib-wide ae fmt is a large diff waiting to happen — probably better as its own deliberate commit than smuggled into a feature.

🤖 Generated with Claude Code

paul-hammant and others added 4 commits September 4, 2026 19:25
tinyweb wraps std.http, whose server has had `server_set_tls` all along --
tinyweb simply never surfaced it, so every tinyweb app was plaintext-only for
no deeper reason than a missing setter.

    server = tinyweb.web_server_host("0.0.0.0", 443) { ... }
    _ = tinyweb.with_tls(server, "/etc/ssl/cert.pem", "/etc/ssl/key.pem")
    err = tinyweb.tw_start(server)

`with_tls` records the PEM pair on the existing config map; tw_start applies it
after bind and BEFORE registering routes, so a bad pair fails start-up rather
than after the server looks ready.

The design decision worth stating: a bad or HALF-configured pair is a start-up
ERROR, not a silent skip. A server that quietly downgrades to plaintext on a
port the caller believes is encrypted is worse than one that refuses to start,
so `with_tls(srv, cert, "")` returns "with_tls needs BOTH a cert and a key"
rather than serving HTTP. That is the same failure shape as the
`response_accept_tunnel` TLS gap found in #1899, where a silent plaintext
fallback had shipped unnoticed downstream.

New suite tests three things: a real TLS handshake serving the route body,
plain http to the TLS port NOT returning the body, and the half-configured
pair refusing to start. It is also the first tests/integration/tinyweb_* suite,
so contrib/tinyweb gains CI coverage it did not have.

Verified on Linux and macOS 15.7.7 (suite passes on both); Windows skips it by
design, as the other HTTP-server suites do, and still compiles the module.
The three existing in-tree tinyweb tests are unaffected. make test 409/409;
fmt gate and check-docs green.

NB the diff is deliberately additive-only (35 insertions, 0 deletions in
module.ae). `ae fmt contrib` reformats 23 unrelated contrib modules and most of
tinyweb's own examples; that noise is reverted here so the change is
reviewable, and the tree stays fmt-canonical either way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The three Linux legs failed with

  integration_tinyweb_tls_half   (TIMED OUT — hung; killed by per-test timeout)
  integration_tinyweb_tls_server (TIMED OUT — hung; killed by per-test timeout)

test-ae runs every .ae under tests/ STANDALONE, so both fixtures were executed
directly rather than only by their shell script -- and tw_start blocks forever,
so each hung until the sweep killed it. The sibling http_server_sse/ is pruned
for exactly this reason; tinyweb_tls/ needed the same entry and did not get it.

Worth noting the difference from the SSE fixture added in #1899, which is NOT
pruned and is nonetheless fine: it ends with sleep(60000); exit(0), so it
self-terminates. Verified that directly rather than assuming it shared this
bug -- it exits 0 when run standalone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The macOS x86_64 leg failed:

  [FAIL] cache_subdir_entry_root_module: an unchanged re-run took 159ms;
         the cache appears never to hit, so every run is a rebuild

That assertion was mine, from #1886, and it was wrong twice over.

The first version compared against an absolute "< 80ms", with a comment
claiming it "leaves generous headroom on a loaded CI runner". It did not: it
encoded the speed of the machine it was written on, and a slow macOS runner
needs 159ms for a genuine cache HIT.

The obvious repair -- time a forced rebuild and require the hit to be markedly
cheaper -- is also unsound, and I only found that by running it five times on a
real Mac rather than once: the "forced rebuild" can itself hit a warm entry
built earlier in the same test, so rebuild and hit measure 43ms and 43ms and
the ratio check fires on a perfectly good cache.

So the timing is gone entirely. A hit REUSES a cache entry and a miss CREATES
one, so counting entries answers the question exactly, on any machine, with no
clock involved. The test now asserts both directions -- an unchanged re-run
must not add an entry, and an edit must -- so it cannot pass by the cache being
broken the other way.

The greeter body is stamped unique per run ($$ + epoch), because a fixed body
already has an entry from an earlier invocation and "did the count grow" would
then answer no for the wrong reason. That was the third flake, found the same
way.

Verified: 3/3 locally and 5/5 on a real macOS 15.7.7 box (the platform that
failed), and it still CATCHES a broken cache -- sabotaging the key so it can
never hit reports "entry (20 -> 21)".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… path

The three Windows legs failed:

  [FAIL] cache_subdir_entry_root_module: editing the module added no cache
         entry (0 -> 0) — the key did not change

Zero on both sides is the tell: the script was counting files in a directory
that does not exist on that machine. It used "$HOME/.aether/cache", but `ae`
resolves the cache under USERPROFILE on Windows (get_home_dir in ae_cache.c
prefers it), and a shell under MSYS2 reports $HOME as a POSIX path. On a GitHub
runner those are genuinely different directories, so the count was 0 either
way and the assertion compared 0 to 0.

This is also why it passed on our own Windows box and failed in CI, which is
worth recording: there HOME=/c/Users/paul and USERPROFILE=C:\Users\paul are the
same directory in two spellings, so the guess happened to work. A developer
machine has a coherent environment; a hermetic runner does not. Same shape as
the -lfyaml finding in #1896, where our box's pkg-config could not see what
CI's could.

`ae cache` prints "Cache: N build(s)" from the same code that writes them, so
there is nothing left to guess. Verified 3/3 locally and 3/3 on the Windows
box, including with HOME deliberately repointed away from USERPROFILE.

Third revision of this assertion (absolute timing, then a rebuild-vs-hit ratio,
now this). The first two were wrong about the machine; this one asks the
program.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@paul-hammant
paul-hammant merged commit 70d20ba into main Sep 4, 2026
27 checks passed
@paul-hammant
paul-hammant deleted the feat/tinyweb-tls branch September 4, 2026 20:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant