Skip to content

Commit 0075138

Browse files
fix(harness): bound requests by one read deadline and the head, not by call slots
Delta security review of #20 at b2cceaa: - D2: a call takes its concurrency slot only once its whole request has arrived, and reading one request (head and body) has one wall-clock deadline instead of a timeout per read, so trickling peers no longer hold every slot. Requests being read are bounded by memory (256 MiB of heads and bodies), refused 503 before any of them is read; at the connection bound the connection idle the longest is closed instead of the newcomer. - D5: the request line and headers are bounded at 32 KiB together (431). - D1: a header line the parser could not read is refused, rather than ending the parsed headers early. - D3: a handler keeps its slot until it returns, past a 504, and the drain at a signal waits for it. - D4: the harness's own concurrency default is named as such, and the docs say how it relates to the runtime's installation concurrency. - D6: idle connections are kept 120 s, longer than Go's 90 s pool, and advertised with Keep-Alive. - D7/D8: the smuggling test covers 404, GET with a body and 503; tests wait on conditions the harness makes observable instead of sleeping. - Nits: Expect: 100-continue gets the refusal instead of 100 Continue; HandlerFailure.code is read-only; the core-bounds test compares numbers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent b2cceaa commit 0075138

6 files changed

Lines changed: 618 additions & 134 deletions

File tree

‎docs/protocol.md‎

Lines changed: 58 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -48,19 +48,58 @@ owner's body would be a field the owner never described.
4848

4949
The body is read only by a `Content-Length` written in ASCII digits, at most
5050
`max-input-bytes`. A missing length is refused `411`; a `Transfer-Encoding`, a
51-
repeated length, a sign or any other spelling is refused `400`, unread; a body
52-
that ends short of its length is refused `400`. **Every answer but a `200`
51+
repeated length, a sign or any other spelling is refused `400`, unread; so is a
52+
request carrying a header line the parser could not read, which would otherwise
53+
end the parsed headers early and hide every header after it. A body that ends
54+
short of its length is refused `400`. A request asking `Expect: 100-continue` is
55+
told the refusal instead of being invited to upload. **Every answer but a `200`
5356
closes the connection** (`Connection: close`), so bytes a refused call did not
5457
consume are never read as the next call — under whatever Work Context those
5558
bytes name.
5659

57-
The harness bounds what a peer can hold. A read or write that stalls for
58-
`IO_TIMEOUT_SECONDS` (10 s) ends the connection, answering `408` when it was a
59-
body that stalled; the same timeout releases an idle kept-alive connection.
60-
Simultaneous calls are bounded by the declaration's `concurrency`
61-
(`DEFAULT_CONCURRENCY`, 32, when it declares 0, the facility default); a call
62-
over the bound is refused `503` before the handler runs. Open connections are
63-
bounded at twice that, and one over it is closed unread.
60+
### What a peer can hold
61+
62+
Every bound below is in `codefly_runnable/protocol.py`, and each is the harness's
63+
own: none is a value a placement resolved.
64+
65+
| Bound | Value | At the bound |
66+
| --- | --- | --- |
67+
| reading one request, head and body together (`REQUEST_READ_SECONDS`) | 10 s wall clock, from its first byte | `408` once the body has begun; a head that has not arrived is dropped unanswered |
68+
| the request line and headers (`MAX_HEAD_BYTES`) | 32 KiB | `431` |
69+
| requests being read at once (`READ_MEMORY_BYTES` ÷ (32 KiB + `max-input-bytes`), at most 1,024) | 256 MiB of heads and bodies: 248 readers at the default 1 MiB input | `503`, before any of the request is read |
70+
| calls whose handler is running (the declared `concurrency`) | `HARNESS_CONCURRENCY`, 32, when the declaration states 0 | `503` |
71+
| open connections (readers + concurrency + `IDLE_CONNECTIONS`, 64) | — | the connection idle the longest is closed to make room; a new one is never refused |
72+
| an idle kept-alive connection (`IDLE_SECONDS`) | 120 s, advertised as `Keep-Alive: timeout=120` | closed |
73+
| one write of an answer (`WRITE_STALL_SECONDS`) | 10 s | the connection is dropped |
74+
75+
The read deadline is one instant per request, not a bound on each read: a peer
76+
sending a byte every few seconds is cut when the request's time is up, however
77+
regularly it sends. A call takes its concurrency slot only once its whole
78+
request has arrived, so a request still arriving holds no slot, and a slow peer
79+
costs a reader — bounded by memory, released at the deadline — rather than a
80+
call. A slot is held until the handler really returns, not until the call is
81+
answered: a call answered `504` at its deadline keeps its slot while its handler
82+
runs on, so the declared concurrency bounds author code running.
83+
84+
**Concurrency is not yet one number.** Core's declaration says `0` means the
85+
facility default and defines none, so the harness admits 32 when the declaration
86+
is silent. The durable-work runtime decides how many calls it dispatches from
87+
its own installation material (1 to 200), not from the declaration. When the
88+
runtime dispatches more than the harness admits, each excess call answers `503`:
89+
an unproven outcome the caller recovers by its recovery policy. Until the
90+
placement passes the number it resolved, declare `concurrency` and install with
91+
the same number. A declaration of `0` is not refused: core's own scaffold
92+
declares no concurrency, so refusing it would refuse every scaffolded runnable.
93+
94+
The idle timeout is longer than the idle timeout of the pool the durable-work
95+
runtime calls through (Go's `net/http`, 90 s by default), so the caller retires
96+
an idle connection before the harness closes it. The other order makes a call
97+
written onto a connection the harness had just closed come back as a reset — an
98+
unproven outcome for a call that never ran. A proxy pooling connections to the
99+
harness needs an idle timeout under 120 s for the same reason; Envoy's default
100+
of one hour is not. Under the connection bound, the harness closing an idle
101+
connection to make room has the same cost, which is why that bound sits beside
102+
every reader and every running call rather than in place of them.
64103

65104
| Route | Answers |
66105
| --- | --- |
@@ -117,8 +156,8 @@ as proof would stop looking for a receipt that exists.
117156
| raised anything else | `500`, no failure code | unproven |
118157
| was still running at the deadline | `504`, no failure code | unproven |
119158
| returned an answer the contract refuses | `500`, no failure code | unproven |
120-
| was never reached (payload, identity, effect id, framing) | `400`/`408`/`411`, no failure code | unproven |
121-
| was never reached (over the concurrency, or stopping) | `503`, no failure code | unproven |
159+
| was never reached (payload, identity, effect id, framing) | `400`/`408`/`411`/`431`, no failure code | unproven |
160+
| was never reached (over a bound, or stopping) | `503`, no failure code | unproven |
122161

123162
The statuses are for humans and proxies: core classifies on the failure-code
124163
header and the answer document, never on a status class.
@@ -136,17 +175,19 @@ representable instant, is refused before the handler runs.
136175

137176
The handler runs on its own thread, because Python cannot raise into one. When
138177
the budget passes the harness answers and the handler is still running; what it
139-
would have returned is no longer that call's outcome.
178+
would have returned is no longer that call's outcome. It keeps its concurrency
179+
slot until it returns.
140180

141181
## Signals and logs
142182

143183
A signal stops the harness taking new calls — on new connections and on ones
144184
already open, which answer `503` with `Connection: close` — and gives the calls
145-
in flight `SHUTDOWN_GRACE_SECONDS` (30 s) to finish. Cutting them off would turn
146-
every one of them into an unproven effect at once. The harness exits `0` as
147-
soon as the last call in flight has answered; an idle connection holds nothing.
148-
If the grace ends with calls still running, they are cut off unanswered and the
149-
harness exits `1`, saying how many effects it left unproven.
185+
in flight `SHUTDOWN_GRACE_SECONDS` (30 s) to finish — including a handler whose
186+
call was already answered at its deadline. Cutting them off would turn every one
187+
of them into an unproven effect at once. The harness exits `0` as soon as the
188+
last handler has returned; an idle connection holds nothing. If the grace ends
189+
with handlers still running, they are cut off and the harness exits `1`, saying
190+
how many effects it left unproven.
150191

151192
`stdout` and `stderr` carry diagnostics only, never completion data: a handler
152193
that printed its output would be indistinguishable from a library that printed a

‎pkg/contract/contract.go‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,9 @@ type Generated struct {
4040
// a call carrying no deadline header is bounded by the author's own
4141
// statement of how long the operation may run.
4242
TimeoutNanoseconds int64 `json:"timeout-nanoseconds"`
43-
// Concurrency is the declared bound on simultaneous calls, which the
44-
// harness enforces before a handler runs; 0 is the facility default.
43+
// Concurrency is the declared bound on handlers running at once, which the
44+
// harness enforces before a handler runs. Core leaves 0 to the facility;
45+
// the harness then applies its own bound (see docs/protocol.md).
4546
Concurrency uint32 `json:"concurrency"`
4647
}
4748

‎pkg/contract/contract_test.go‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ package contract_test
33
import (
44
"fmt"
55
"io/fs"
6+
"regexp"
7+
"strconv"
68
"strings"
79
"testing"
810

@@ -77,8 +79,12 @@ func TestTheHeaderBoundsAreCoresBounds(t *testing.T) {
7779
if limit == 0 {
7880
t.Fatalf("core declares no max_len on %s", field.FullName())
7981
}
80-
if declaration := fmt.Sprintf("%s = %d", bound.literal, limit); !strings.Contains(protocol, declaration) {
81-
t.Errorf("the harness does not declare %s, core's bound on %s", declaration, field.FullName())
82+
declared := regexp.MustCompile(`(?m)^`+bound.literal+` = ([0-9]+)$`).FindAllStringSubmatch(protocol, -1)
83+
if len(declared) != 1 {
84+
t.Fatalf("the harness declares %s %d times, not once", bound.literal, len(declared))
85+
}
86+
if value, err := strconv.ParseUint(declared[0][1], 10, 64); err != nil || value != limit {
87+
t.Errorf("the harness declares %s = %s; core bounds %s at %d", bound.literal, declared[0][1], field.FullName(), limit)
8288
}
8389
}
8490
}

‎pkg/harness/codefly_runnable/protocol.py‎

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -45,14 +45,40 @@
4545
# caller as no assertion at all while the handler believed it had asserted.
4646
FAILURE_CODE_PATTERN = re.compile(r"[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}")
4747

48-
# How long one read or write on a call's connection may stall, and how long an
49-
# idle kept-alive connection is held. Without it a peer that stops sending holds
50-
# a thread for as long as it likes.
51-
IO_TIMEOUT_SECONDS = 10.0
52-
53-
# Simultaneous calls when the declaration's concurrency is 0, the facility
54-
# default. A call over the bound is refused before the handler runs.
55-
DEFAULT_CONCURRENCY = 32
48+
# How long reading one request may take, its head and its body together. It is
49+
# one wall-clock deadline rather than a bound on each read, which a peer sending
50+
# a byte just inside it would hold open forever.
51+
REQUEST_READ_SECONDS = 10.0
52+
53+
# How long one write of an answer may stall before the connection is dropped.
54+
WRITE_STALL_SECONDS = 10.0
55+
56+
# How long a kept-alive connection may sit idle between calls. It is longer than
57+
# the idle timeout of the pool the durable-work runtime calls through (Go's
58+
# net/http, 90 s by default), so the caller retires an idle connection before
59+
# the harness closes it: a call written onto a connection the harness had just
60+
# closed would come back as a reset, an uncertain outcome for a call that never
61+
# ran. Answers advertise it in a Keep-Alive header for clients that read one.
62+
IDLE_SECONDS = 120.0
63+
64+
# The request line and every header together. The per-call facts need under
65+
# 9 KiB at core's bounds; the stdlib alone would admit 100 lines of 64 KiB.
66+
MAX_HEAD_BYTES = 32 * 1024
67+
68+
# Memory for requests being read, head and body. It sizes how many requests are
69+
# read at once: a request over that count is refused before anything of it is
70+
# read, and a call holds no slot while its request is still arriving.
71+
READ_MEMORY_BYTES = 256 * 1024 * 1024
72+
MAX_READERS = 1024
73+
74+
# Idle connections kept beside every request being read and every call
75+
# running. Past them, a new connection closes the one idle the longest.
76+
IDLE_CONNECTIONS = 64
77+
78+
# The harness's own bound on simultaneous calls when the declaration states
79+
# none (concurrency 0, which core leaves to the facility). It is this harness's
80+
# number, not one any placement resolved; see docs/protocol.md.
81+
HARNESS_CONCURRENCY = 32
5682

5783
# How long a call in flight has to finish after a signal. A handler interrupted
5884
# mid-effect leaves it unproven, so the process waits rather than making every
@@ -79,9 +105,14 @@ def __init__(self, code: str, message: str) -> None:
79105
raise ValueError(f"failure code must match {FAILURE_CODE_PATTERN.pattern}")
80106
if not isinstance(message, str):
81107
raise ValueError("failure message must be a string")
82-
self.code = code
108+
self._code = code
83109
super().__init__(message)
84110

111+
@property
112+
def code(self) -> str:
113+
"""The validated code. Read-only: what was checked is what is sent."""
114+
return self._code
115+
85116

86117
@dataclass(frozen=True)
87118
class RunnableIdentity:

0 commit comments

Comments
 (0)