Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -126,16 +126,16 @@ jobs:
uses: actions/cache@v5
with:
path: /opt/openssl-3.6
key: openssl-3.6.0-${{ runner.os }}-${{ runner.arch }}
key: openssl-3.6.2-${{ runner.os }}-${{ runner.arch }}

- name: Build OpenSSL 3.6
if: steps.cache-openssl36.outputs.cache-hit != 'true'
run: |
sudo apt-get -y install build-essential
cd /tmp
wget -q https://www.openssl.org/source/openssl-3.6.0.tar.gz
tar -xzf openssl-3.6.0.tar.gz
cd openssl-3.6.0
wget -q https://www.openssl.org/source/openssl-3.6.2.tar.gz
tar -xzf openssl-3.6.2.tar.gz
cd openssl-3.6.2
./Configure --prefix=/opt/openssl-3.6 shared no-docs
make -j$(nproc)
sudo mkdir -p /opt/openssl-3.6
Expand Down Expand Up @@ -394,6 +394,14 @@ jobs:
## Tests
##

- name: Build fake_upstream
if: matrix.build == 'unit' || matrix.build == 'python-3.11' || matrix.build == 'python-3.12'
run: |
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
source "$HOME/.cargo/env"
cargo build --release --manifest-path test/fake_upstream/Cargo.toml
sudo cp test/fake_upstream/target/release/fake_upstream /usr/local/bin/

# /home/runner will be root only after calling sudo above
# Ensure all users and processes can execute
- name: Fix permissions
Expand Down
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,8 @@ __pycache__/
/FIX.md
/CLAUDE.original.md
/.qwen/skills/clang-ast/SKILL.md
/.qwen/.qwen/settings.json
/.qwen/.qwen/settings.json.orig
/.qwen/.qwen/skills/clang-ast/SKILL.md
test/fake_upstream/target/
test/fake_upstream/Cargo.lock
33 changes: 32 additions & 1 deletion CHANGES
Original file line number Diff line number Diff line change
@@ -1,5 +1,36 @@

Changes with FreeUnit 1.35.4 xx xxx 2026
Changes with FreeUnit 1.35.5 29 May 2026

*) Feature: automatically convert chunked request bodies to Content-Length
when forwarding to upstream servers via proxy action. This enables
compatibility with backends that do not support Transfer-Encoding:
chunked (e.g., Gitea, servers requiring Content-Length). Fixes
freeunitorg/freeunit#58, resolves nginx/unit#445 (client chunked),
nginx/unit#1088 (duplicate TE), and nginx/unit#1278 (RFC 9112 epic).

*) Change: chunked_transform feature is no longer experimental. Chunked
request bodies can be accepted and transparently converted to
Content-Length via configuration: { "settings": { "http":
{ "chunked_transform": true } } }

*) Bugfix: fix TLS library busy-loop on peer-initiated close in SSL_write
when connection is aborted by remote peer; prevents high CPU usage and
ensures proper connection cleanup.

*) Feature: add unfreeze-sync.sh script for automated migration of issues
from nginx/unit to freeunitorg/freeunit with label mapping, deduplication,
and dry-run preview support.

*) Change: upgrade contrib njs to 0.9.8.

*) Bugfix: fix mem-pool retain leak in cert/script-store IPC paths
(router side) and fd/buffer leaks in cert/script/socket/access-log
reply paths and the controller config-store path (main process
side); all reachable when nxt_port_msg_alloc fails inside the
port machinery.


Changes with FreeUnit 1.35.4 30 Apr 2026

*) Bugfix: fix router process CPU spin and connection hang under port
scanning load; CLOSE-WAIT sockets are now cleaned up properly on
Expand Down
60 changes: 58 additions & 2 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,8 +135,11 @@ Before the OpenSSL 3.6 migration can be considered fully validated:
`OBJ_sn2nid` / `OpenSSL_version_num` replacements.
- [ ] Run the full CI matrix (`ci.yml`) and confirm the new "Build OpenSSL 3.6"
step succeeds on both `amd64` and `arm64` runners.
- [ ] Confirm `clang-ast` workflow passes end-to-end on `debian:testing`
(was broken by `EVP_PKEY_asn1_find_str` / `SSLeay` deprecations).
- [x] `clang-ast` workflow passes on `debian:testing` + system OpenSSL 1.1
via `./test/run-local-full.sh` (verified on `pre-1.35.5` branch).
- [ ] Confirm `clang-ast` still passes when linked against OpenSSL 3.6
(previously broken by `EVP_PKEY_asn1_find_str` / `SSLeay` deprecations
— fixes need re-verification on the 3.6 build).
- [ ] Smoke-test TLS in a Docker image built from `Dockerfile.minimal`
(now `debian:trixie-slim`) — load a certificate via the REST API and
make an HTTPS request.
Expand Down Expand Up @@ -180,3 +183,56 @@ inside a chroot/rootfs-isolated Unit application.
1. Run `ldd $(which php)` with PHP 8.5 and compare against the rootfs fixture contents
2. Check `unit.log` for the full path that caused the segfault (needs core dump or `strace`)
3. Check if `php 8.5 --define open_basedir=...` reproduces outside of Unit

---

## Test Infrastructure

### Prebuild `fake_upstream` binary via packages.freeunit.org

`test/fake_upstream/` — Rust HTTP mock used by `test_proxy_chunked.py`.
Currently built from source in Docker (`cargo build --release`), adding ~0.5s per run.

**Improvement:**
- [ ] Build `fake_upstream` binary and publish to `packages.freeunit.org`
- [ ] Update `run-local.sh` to download prebuilt binary instead of `cargo build`
- [ ] Add SHA-512 checksum validation (like `pkg/contrib/Makefile` does for njs/wasmtime)
- [ ] Fallback to cargo build if download fails

**Benefits:**
- Faster test image builds
- Reproducible binaries across platforms (AMD64 + ARM64)
- No Rust toolchain required in Docker image

---

### clang-ast Docker build: debian:testing + `clang llvm-dev libclang-dev`

`test/run-local-full.sh` builds a Docker image for clang-ast analysis.
Fixed: use `clang llvm-dev libclang-dev` (not `clang-21 llvm-21-dev libclang-21-dev`).

**Current state:** Works on `debian:testing` (clang 21 + llvm 21).

**Future improvements:**
- [ ] Prebuild `freeunit-test-full:local` image and publish to GHCR
- [ ] Or add packages.freeunit.org binary for clang-ast plugin
- [ ] Cache Docker layers for apt install + clang-ast build

---

## Chunked Encoding (RFC 9112) — Implemented in pre-1.35.5-i58

Branch `pre-1.35.5-i58` implements automatic chunked → Content-Length conversion
for proxy request forwarding. Key files:

- `src/nxt_h1proto.c` — buffer fix (L1149-1171) + CL injection (L2414-2475)
- `test/test_proxy_chunked.py` — 10 tests (all passing)
- `test/fake_upstream/` — Rust HTTP mock with strict CL validation

**Tests:** 10/10 passed ✅
**clang-ast:** PASSED ✅

**Pending upstream:**
- Consider making the conversion configurable (currently always-on when `r->chunked`)
- Add metrics/counter for chunked → CL conversions
- Consider adding `Transfer-Encoding` removal for HTTP/2 upstream (HTTP/2 doesn't use TE header)
25 changes: 22 additions & 3 deletions src/nxt_cert.c
Original file line number Diff line number Diff line change
Expand Up @@ -1114,7 +1114,6 @@ nxt_cert_store_get(nxt_task_t *task, nxt_str_t *name, nxt_mp_t *mp,
goto fail;
}

nxt_mp_retain(mp);
b->completion_handler = nxt_cert_buf_completion;
Comment thread
andypost marked this conversation as resolved.

nxt_buf_cpystr(b, name);
Expand All @@ -1138,6 +1137,13 @@ nxt_cert_store_get(nxt_task_t *task, nxt_str_t *name, nxt_mp_t *mp,
goto fail;
}

/*
* Retain only after the buffer has been handed off to the port machinery,
* so that the failure paths above do not leave the pool with a refcount
* that the completion handler can never release.
*/
nxt_mp_retain(mp);

return;

fail:
Expand Down Expand Up @@ -1224,8 +1230,21 @@ nxt_cert_store_get_handler(nxt_task_t *task, nxt_port_recv_msg_t *msg)

error:

(void) nxt_port_socket_write(task, port, type, file.fd,
msg->port_msg.stream, 0, NULL);
if (nxt_port_socket_write(task, port, type, file.fd,
msg->port_msg.stream, 0, NULL)
!= NXT_OK
&& file.fd != -1)
{
/*
* On send failure (e.g. malloc failure inside the port machinery)
* the port layer never takes ownership of the fd, so close it
* here to avoid leaking an open file descriptor in the privileged
* main process. Use nxt_fd_close() rather than nxt_file_close():
* file.name has already been freed above and the latter would
* dereference it through "%FN" on a close-failure log path.
*/
nxt_fd_close(file.fd);
Comment thread
andypost marked this conversation as resolved.
}
}


Expand Down
20 changes: 17 additions & 3 deletions src/nxt_controller.c
Original file line number Diff line number Diff line change
Expand Up @@ -2446,6 +2446,7 @@ nxt_controller_conf_store(nxt_task_t *task, nxt_conf_value_t *conf)
u_char *end;
size_t size;
nxt_fd_t fd;
nxt_int_t rc;
nxt_buf_t *b;
nxt_port_t *main_port;
nxt_runtime_t *rt;
Expand Down Expand Up @@ -2479,9 +2480,22 @@ nxt_controller_conf_store(nxt_task_t *task, nxt_conf_value_t *conf)

b->mem.free = nxt_cpymem(b->mem.pos, &size, sizeof(size_t));

(void) nxt_port_socket_write(task, main_port,
NXT_PORT_MSG_CONF_STORE | NXT_PORT_MSG_CLOSE_FD,
fd, 0, -1, b);
rc = nxt_port_socket_write(task, main_port,
NXT_PORT_MSG_CONF_STORE | NXT_PORT_MSG_CLOSE_FD,
fd, 0, -1, b);

if (nxt_slow_path(rc != NXT_OK)) {
/*
* Port layer did not take ownership of fd or b (e.g. malloc
* failure inside nxt_port_msg_alloc); close the shm fd and
* queue the buffer completion so the engine memory pool is
* not left with an unreclaimed buffer.
*/
nxt_fd_close(fd);

nxt_work_queue_add(&task->thread->engine->fast_work_queue,
b->completion_handler, task, b, b->parent);
}

return;

Expand Down
54 changes: 52 additions & 2 deletions src/nxt_h1proto.c
Original file line number Diff line number Diff line change
Expand Up @@ -1149,6 +1149,24 @@ nxt_h1p_conn_request_body_read(nxt_task_t *task, void *obj, void *data)

if (h1p->chunked_parse.last) {
body_rest = 0;

} else if (h1p->chunked_parse.chunk_size > 0) {
/* Mid-chunk: chunk_parse consumed the entire buffer but did not
* advance b->mem.pos (CHUNK_MIDDLE path in chunk_buffer).
* Reset so nxt_conn_read has space on the next iteration. */
b->mem.free = b->mem.start;
b->mem.pos = b->mem.start;

} else {
/* Between chunks: chunk_parse advanced b->mem.pos past all
* framing. Compact any leftover bytes to the front so
* nxt_conn_read appends after them. */
size = (size_t) (b->mem.free - b->mem.pos);
if (size > 0) {
nxt_memmove(b->mem.start, b->mem.pos, size);
}
b->mem.free = b->mem.start + size;
b->mem.pos = b->mem.start;
}

} else {
Expand Down Expand Up @@ -2380,6 +2398,7 @@ nxt_h1p_peer_header_send(nxt_task_t *task, nxt_http_peer_t *peer)
nxt_conn_t *c;
nxt_http_field_t *field;
nxt_http_request_t *r;
nxt_off_t content_length;

nxt_debug(task, "h1p peer header send");

Expand All @@ -2395,9 +2414,34 @@ nxt_h1p_peer_header_send(nxt_task_t *task, nxt_http_peer_t *peer)
+ sizeof("Connection: close\r\n")
+ sizeof("\r\n");

/* If request body needs Content-Length (e.g., after chunked_transform),
calculate it from the buffered body. Empty chunked body (0\r\n\r\n
only) leaves r->body == NULL — still emit Content-Length: 0 for
backends that require it. */
content_length = -1;
if (r->chunked) {
if (r->body == NULL) {
content_length = 0;
} else {
nxt_buf_t *b;

content_length = 0;

for (b = r->body; b != NULL; b = b->next) {
if (nxt_buf_is_file(b)) {
content_length += b->file_end - b->file_pos;
} else {
content_length += nxt_buf_mem_used_size(&b->mem);
}
}
}
/* Account for Content-Length header size (max off_t length + "Content-Length: \r\n"). */
size += nxt_length("Content-Length: ") + NXT_OFF_T_LEN + nxt_length("\r\n");
}

nxt_list_each(field, r->fields) {

if (!field->hopbyhop) {
if (!field->hopbyhop && !field->skip) {
size += field->name_length + field->value_length;
size += nxt_length(": \r\n");
}
Expand All @@ -2419,7 +2463,7 @@ nxt_h1p_peer_header_send(nxt_task_t *task, nxt_http_peer_t *peer)

nxt_list_each(field, r->fields) {

if (!field->hopbyhop) {
if (!field->hopbyhop && !field->skip) {
p = nxt_cpymem(p, field->name, field->name_length);
*p++ = ':'; *p++ = ' ';
p = nxt_cpymem(p, field->value, field->value_length);
Expand All @@ -2428,6 +2472,12 @@ nxt_h1p_peer_header_send(nxt_task_t *task, nxt_http_peer_t *peer)

} nxt_list_loop;

if (content_length >= 0) {
p = nxt_cpymem(p, "Content-Length: ", nxt_length("Content-Length: "));
p = nxt_sprintf(p, header->mem.end, "%O", content_length);
*p++ = '\r'; *p++ = '\n';
}

*p++ = '\r'; *p++ = '\n';
header->mem.free = p;
size = p - header->mem.pos;
Expand Down
40 changes: 36 additions & 4 deletions src/nxt_main_process.c
Original file line number Diff line number Diff line change
Expand Up @@ -1170,8 +1170,30 @@ nxt_main_port_socket_handler(nxt_task_t *task, nxt_port_recv_msg_t *msg)
type = NXT_PORT_MSG_RPC_ERROR;
}

nxt_port_socket_write(task, port, type, ls.socket, msg->port_msg.stream,
0, out);
if (nxt_port_socket_write(task, port, type, ls.socket, msg->port_msg.stream,
0, out)
!= NXT_OK)
{
/*
* ls.socket is -1 unless nxt_main_listening_socket() succeeded.
* In that case the port layer did not take ownership, so close it
* explicitly.
*/
if (ls.socket != -1) {
nxt_socket_close(task, ls.socket);
}

/*
* The buffer never reached the port queue, so the port layer will
* not run its completion. Queue the completion to match normal
* port-layer cleanup semantics.
*/
if (out != NULL) {
nxt_work_queue_add(&task->thread->engine->fast_work_queue,
out->completion_handler, task, out,
out->parent);
}
}
Comment thread
andypost marked this conversation as resolved.
}


Expand Down Expand Up @@ -1728,8 +1750,18 @@ nxt_main_port_access_log_handler(nxt_task_t *task, nxt_port_recv_msg_t *msg)
msg->port_msg.reply_port);

if (nxt_fast_path(port != NULL)) {
(void) nxt_port_socket_write(task, port, type, file.fd,
msg->port_msg.stream, 0, NULL);
if (nxt_port_socket_write(task, port, type, file.fd,
msg->port_msg.stream, 0, NULL)
!= NXT_OK
&& file.fd != -1)
{
/*
* Port layer never took ownership of the fd (e.g. malloc
* failure inside nxt_port_msg_alloc); close it explicitly to
* avoid leaking the open file in the main process.
*/
nxt_file_close(task, &file);
}

} else {
nxt_file_close(task, &file);
Expand Down
Loading
Loading