Skip to content

Commit f8a56d9

Browse files
fix(harness): exit normally at a stop, and never wait on stderr
Round 4 security review of #20 at b4da6f0: - N-c: the stop is an ordinary interpreter exit again, so the handler module's atexit hooks run and its buffers are flushed. What made shutdown abort was harness threads holding stderr's lock; the harness's own diagnostics now queue for one writer that writes the descriptor directly, and an unanswered connection error is one of those diagnostics rather than a stdlib traceback. Only past the grace, beside author code still running, does the process run its atexit hooks within 2 s and exit directly. - Pre-existing: no diagnostic waits on an unread stderr, so a call's diagnostic no longer holds its slot and the exit-1 message cannot hold the exit. - N-b: the failure code is read once, checked, and that value is sent; a code that raises when read asserts nothing. - N-d: the read-budget test's body is refused only at its last element, so removing the decode lock fails it. - N-a: docs/protocol.md states the decode queue's latency bound. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent b4da6f0 commit f8a56d9

5 files changed

Lines changed: 292 additions & 36 deletions

File tree

‎docs/protocol.md‎

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,15 @@ budget the process holds one document being decoded and, under each running
9494
call's slot, a payload its contract admits — the envelope the declaration's
9595
`max-input-bytes` and `concurrency` state.
9696

97+
Decoding one call at a time is first come, first served, so a small call can
98+
wait behind every decode queued ahead of it. The wait is bounded: admission comes
99+
before the queue, so at most min(concurrency, readers) calls are ahead, each one
100+
decode and check of at most `max-input-bytes`. A 1 MiB body whose last element
101+
is the one the schema refuses takes about 0.2 s, so at concurrency 32 a call
102+
waits at most about six seconds behind a full queue. A review measured the
103+
median of small calls rising from 0.58 s to 1.69 s against 24 peers looping such
104+
bodies, every call still answered `200`.
105+
97106
**Concurrency is not yet one number.** Core's declaration says `0` means the
98107
facility default and defines none, so the harness admits 32 when the declaration
99108
is silent. The durable-work runtime decides how many calls it dispatches from
@@ -156,9 +165,11 @@ The code becomes a response header, so it must match
156165
`^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}$` — the grammar the durable-work runtime reads
157166
it with, which is narrower than core's 1 to 128 characters. A code outside it
158167
raises `ValueError` where it is constructed, and the handler's call then answers
159-
as any untyped error does: unproven. The code is read-only, and checked again
160-
where the header is set: a code changed after construction answers `500` with
161-
no failure code.
168+
as any untyped error does: unproven. The code is read-only, and where the
169+
header is set it is read exactly once, checked, and that same value is sent: a
170+
code changed after construction — even by a subclass whose `code` property
171+
returns something else on a later read — answers `500` with no failure code, and
172+
so does a code that raises when read.
162173

163174
Everything else leaves the effect **unproven**, because the harness cannot know
164175
which side of its effect a handler stopped on. A caller that read a bare status
@@ -200,15 +211,26 @@ already open, which answer `503` with `Connection: close` — and gives the call
200211
in flight `SHUTDOWN_GRACE_SECONDS` (30 s) to finish — including a handler whose
201212
call was already answered at its deadline. Cutting them off would turn every one
202213
of them into an unproven effect at once. The harness exits `0` as soon as the
203-
last handler has returned; an idle connection holds nothing. The process ends
204-
with that code directly rather than through interpreter shutdown, which can
205-
abort while connection threads are still parked or writing a diagnostic. If the grace ends
206-
with handlers still running, they are cut off and the harness exits `1`, saying
207-
how many effects it left unproven.
214+
last handler has returned; an idle connection holds nothing. It is an ordinary
215+
interpreter exit: the handler module's `atexit` hooks run and its buffered
216+
writes are flushed, so a `logging` handler, a telemetry exporter or a client
217+
that flushes at exit still does. If the grace ends with handlers still running,
218+
they are cut off and the harness exits `1`, saying how many effects it left
219+
unproven. That exit cannot wait for interpreter shutdown beside author code
220+
that is still running and may hold any lock, so the `atexit` hooks run and the
221+
standard streams are flushed for at most `CLOSE_SECONDS` (2 s), and the process
222+
then ends with `1` directly; a buffered write the handler never flushed and no
223+
hook flushes is lost there.
208224

209225
`stdout` and `stderr` carry diagnostics only, never completion data: a handler
210226
that printed its output would be indistinguishable from a library that printed a
211-
warning. The harness does **not** bound them. Per-call stream bounding is not
227+
warning. The harness's own diagnostics never wait on the stream: they queue for
228+
one writer that writes the descriptor directly, so a placement that does not
229+
read stderr cannot stall a call that holds its slot, and no harness thread holds
230+
the stream's lock when the process exits. Past 1,024 queued diagnostics a new
231+
one is dropped and counted, and the count is written at exit if the stream then accepts it. A handler's own
232+
`print` and `context.log` write the stream as any code does, and wait on it as
233+
any code does. The harness does **not** bound the streams. Per-call stream bounding is not
212234
something a server can do — concurrent calls share the process's descriptors —
213235
and `max_log_bytes` describes what the placement capturing those streams
214236
enforces, which is where it belongs.

‎pkg/contract/contract_test.go‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,8 +100,9 @@ func TestTheFailureCodeHeaderIsSetInOnePlace(t *testing.T) {
100100
if occurrences := strings.Count(runner, "FAILURE_CODE_HEADER"); occurrences != 2 {
101101
t.Fatalf("the failure-code header is named %d times: once in the import and once where it is set", occurrences)
102102
}
103-
if !strings.Contains(runner, "self.send_header(FAILURE_CODE_HEADER, answer.failure.code)") {
104-
t.Error("the failure-code header no longer carries the handler's own declared code")
103+
if !strings.Contains(runner, "self.send_header(FAILURE_CODE_HEADER, code)") ||
104+
strings.Count(runner, "code = _code_of(answer.failure)") != 1 {
105+
t.Error("the failure-code header no longer carries the handler's own declared code, read once and checked")
105106
}
106107
}
107108

‎pkg/harness/codefly_runnable/protocol.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,10 @@
8080
# number, not one any placement resolved; see docs/protocol.md.
8181
HARNESS_CONCURRENCY = 32
8282

83+
# How long the harness gives its own connection threads to end, and its
84+
# diagnostics to be written, once the calls in flight have finished.
85+
CLOSE_SECONDS = 2.0
86+
8387
# How long a call in flight has to finish after a signal. A handler interrupted
8488
# mid-effect leaves it unproven, so the process waits rather than making every
8589
# call in flight unproven at once.

‎pkg/harness/codefly_runnable/runner.py‎

Lines changed: 103 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,12 @@
1414

1515
from __future__ import annotations
1616

17+
import atexit
1718
import importlib
1819
import io
1920
import json
2021
import os
22+
import queue
2123
import re
2224
import signal
2325
import socket
@@ -34,6 +36,7 @@
3436
from .contract import CONTRACT_FILE, RECEIPT_ATTRIBUTE, Contract
3537
from .protocol import (
3638
ADDRESS_VARIABLE,
39+
CLOSE_SECONDS,
3740
FAILURE_CODE_HEADER,
3841
FAILURE_CODE_PATTERN,
3942
HARNESS_CONCURRENCY,
@@ -142,6 +145,58 @@ def drain(self, timeout: float) -> int:
142145
return self._active
143146

144147

148+
class _Diagnostics:
149+
"""The harness's own diagnostics, written so that nothing waits on stderr.
150+
151+
A placement that never reads the stream would otherwise stall whichever
152+
thread printed — and a request thread printing holds its call's slot, so
153+
every later call would be refused. Messages queue for one writer, which
154+
writes the descriptor directly and so holds no stream lock that a thread
155+
or interpreter shutdown could wait on. Past the queue's bound a message is
156+
dropped and counted rather than waited for.
157+
"""
158+
159+
def __init__(self, capacity: int = 1024) -> None:
160+
self._queue: queue.Queue[str] = queue.Queue(capacity)
161+
self._dropped = 0
162+
self._lock = threading.Lock()
163+
self._closing = threading.Event()
164+
self._writer = threading.Thread(target=self._write, name="codefly-diagnostics", daemon=True)
165+
self._writer.start()
166+
167+
def say(self, message: str) -> None:
168+
try:
169+
self._queue.put_nowait(message)
170+
except queue.Full:
171+
with self._lock:
172+
self._dropped += 1
173+
174+
def _write(self) -> None:
175+
while not (self._closing.is_set() and self._queue.empty()):
176+
try:
177+
message = self._queue.get(timeout=0.05)
178+
except queue.Empty:
179+
continue
180+
data = f"[codefly] {message}\n".encode("utf-8", "backslashreplace")
181+
try:
182+
while data:
183+
data = data[os.write(2, data):]
184+
except OSError:
185+
pass
186+
187+
def close(self, timeout: float) -> None:
188+
"""Write what is queued, for at most this long."""
189+
with self._lock:
190+
dropped, self._dropped = self._dropped, 0
191+
if dropped:
192+
self.say(f"{dropped} diagnostics were dropped while stderr was not being read")
193+
self._closing.set()
194+
self._writer.join(timeout)
195+
196+
197+
_DIAGNOSTICS = _Diagnostics()
198+
199+
145200
def main() -> int:
146201
"""Serve this runnable until it is signalled. The return value is the exit code."""
147202
root = Path(__file__).resolve().parent.parent
@@ -161,7 +216,8 @@ def main() -> int:
161216
calls = _Calls(contract.concurrency or HARNESS_CONCURRENCY)
162217
server = _server(address, contract, calls, handle, receipt_of)
163218
except (OSError, KeyError, ValueError, ProtocolError, AttributeError, ImportError) as err:
164-
print(f"[codefly] {err}", file=sys.stderr)
219+
_DIAGNOSTICS.say(str(err))
220+
_DIAGNOSTICS.close(CLOSE_SECONDS)
165221
return 1
166222

167223
stopping = threading.Event()
@@ -190,26 +246,38 @@ def stop(*_: object) -> None:
190246
# the last one has returned; an idle connection holds nothing.
191247
remaining = calls.drain(SHUTDOWN_GRACE_SECONDS)
192248
if remaining:
193-
print(
194-
f"[codefly] {remaining} handlers were still running when the {SHUTDOWN_GRACE_SECONDS:g}s grace ended; "
195-
"their effects are unproven",
196-
file=sys.stderr,
249+
_DIAGNOSTICS.say(
250+
f"{remaining} handlers were still running when the {SHUTDOWN_GRACE_SECONDS:g}s grace ended; "
251+
"their effects are unproven"
197252
)
198-
_exit(1 if remaining else 0)
199-
return 1 if remaining else 0
253+
# No thread the harness started holds a stream lock — its diagnostics are
254+
# written to the descriptor directly — so interpreter shutdown cannot
255+
# abort on one, and the process exits normally: the handler module's
256+
# atexit hooks run and its buffers are flushed.
257+
_DIAGNOSTICS.close(CLOSE_SECONDS)
258+
code = 1 if remaining else 0
259+
if remaining:
260+
_exit_now(code)
261+
return code
200262

201263

202-
def _exit(code: int) -> None:
203-
"""End the process with this code, whatever its connection threads are doing.
264+
def _exit_now(code: int) -> None:
265+
"""End the process with this code while threads the harness cannot stop are still running.
204266
205-
Interpreter finalization with daemon threads still parked on connections —
206-
or holding the lock of a stream they are writing a diagnostic to — can
207-
abort instead of exiting, and a placement would read a crash where the code
208-
says what happened to the calls. Streams get a bounded moment to flush.
267+
A handler past the grace is author code, and it may hold any lock —
268+
including a stream's — so interpreter shutdown beside it could abort, and a
269+
placement would read a crash where the code says what happened to the
270+
calls. The handler module's atexit hooks still run and the standard
271+
streams are flushed, for at most CLOSE_SECONDS, before the process ends.
209272
"""
210-
flushing = threading.Thread(target=lambda: (sys.stdout.flush(), sys.stderr.flush()), daemon=True)
211-
flushing.start()
212-
flushing.join(2.0)
273+
def finish() -> None:
274+
atexit._run_exitfuncs()
275+
sys.stdout.flush()
276+
sys.stderr.flush()
277+
278+
finishing = threading.Thread(target=finish, daemon=True)
279+
finishing.start()
280+
finishing.join(CLOSE_SECONDS)
213281
os._exit(code)
214282

215283

@@ -287,6 +355,10 @@ def process_request(self, request, client_address) -> None:
287355
self.register(request)
288356
super().process_request(request, client_address)
289357

358+
def handle_error(self, request, client_address) -> None:
359+
"""An exception a connection thread did not answer: a diagnostic, not a traceback on stderr."""
360+
_DIAGNOSTICS.say(f"a connection ended on {_describe(sys.exc_info()[1])}")
361+
290362
def register(self, request) -> None:
291363
"""Count a new connection in, idle, closing the one idle the longest if it is at the bound."""
292364
with self._lock:
@@ -520,7 +592,8 @@ def _decode(self, body: bytes) -> Call | _Answer:
520592
def _answer(self, answer: _Answer) -> None:
521593
# Anything but a success may have left request bytes unread, and a
522594
# stopping harness keeps no connection for a call it will refuse.
523-
if answer.failure is not None and not _asserts(answer.failure):
595+
code = _code_of(answer.failure) if answer.failure is not None else None
596+
if answer.failure is not None and code is None:
524597
# Checked again where it is sent: a code changed after it was
525598
# constructed asserts nothing, and never reaches a header.
526599
answer = _Answer(500, code="internal", message="the handler's failure code changed after it was checked")
@@ -530,7 +603,7 @@ def _answer(self, answer: _Answer) -> None:
530603
# The one place this header is set. A caller reads it before the
531604
# status, and without it no status proves anything.
532605
self.send_response(answer.status)
533-
self.send_header(FAILURE_CODE_HEADER, answer.failure.code)
606+
self.send_header(FAILURE_CODE_HEADER, code)
534607
body = _error("failed_precondition", str(answer.failure))
535608
elif answer.status == 200:
536609
self.send_response(200)
@@ -548,7 +621,7 @@ def _answer(self, answer: _Answer) -> None:
548621
self.end_headers()
549622
self.wfile.write(body)
550623
if answer.message:
551-
print(f"[codefly] {answer.message}", file=sys.stderr)
624+
_DIAGNOSTICS.say(answer.message)
552625

553626
def log_request(self, *_: object) -> None:
554627
"""No request log: an answer's diagnostics are printed by _answer."""
@@ -559,9 +632,17 @@ def log_message(self, *_: object) -> None:
559632
return _Server(host, port, Handler, readers=readers, connections=readers + calls.limit + IDLE_CONNECTIONS)
560633

561634

562-
def _asserts(failure: HandlerFailure) -> bool:
563-
"""Whether a failure's code is still one the grammar admits."""
564-
return isinstance(failure.code, str) and FAILURE_CODE_PATTERN.fullmatch(failure.code) is not None
635+
def _code_of(failure: HandlerFailure) -> str | None:
636+
"""The failure's code, read once, or None when it is no longer one the grammar admits.
637+
638+
Read once because the value checked must be the value sent: a code that
639+
could change between the two reads would reach the header unchecked.
640+
"""
641+
try:
642+
code = failure.code
643+
except Exception: # noqa: BLE001 — a code that cannot be read asserts nothing
644+
return None
645+
return code if isinstance(code, str) and FAILURE_CODE_PATTERN.fullmatch(code) else None
565646

566647

567648
def _listen(address: str) -> tuple[str, int]:

0 commit comments

Comments
 (0)