仕組み
ADR-0010: Resource governance, backtest capacity and cancellation semantics
これらのページは英語です。仕様は一つの言語で保つことで、一つのことだけを語ります。
Status: accepted and implemented (2026-10-10).
Context#
Executions run inside TTT's process (ADR-0002). The runtime had separate
limits (steps, depth, list length, string length, output lines, host
calls), but nothing bounded how much memory a program's values hold in
total: a program could keep many lists each under the length limit, or
pull large host results. Backtests a program submits were unbounded: a
loop of backtest.submit started one engine thread per iteration. The
CLI documented exit code 4 for cancellation but could not cancel. Closing
the terminal discarded an unsaved .tz program without a word.
Decision#
1. One memory budget per execution, in live bytes#
- Every string (
Text) and every list or record (Items) carries a charge: added to the running execution's budget when it is built, returned when its last reference drops (Drop), so quota is returned on every path — success, error, cancellation, a panic caught at the thread boundary. List storage is charged by capacity, plus the box of each boxed element. - Sizes known in advance (
list.range, joins, copies, apushthat will reallocate, a list literal) are refused before allocation; everything is also checked after every evaluation step. A single operation can overshoot by at most its own result, which the length limits bound. - Exhaustion is
E-RT-LIMIT-MEMORY; no partial result is reported as complete. The report carries the execution's peak. Limits::max_memorydefaults to 512 MiB; it is set by the host that starts the execution, never by the program.
What this is not: an operating-system memory limit. It does not count the interpreter's frames, the compiler, a host's transient buffers before conversion, or allocator overhead, and it cannot stop other code in the process from allocating. A hard limit needs a separate process (ADR-0009).
2. Output bounded in bytes as well as lines#
max_output_bytes (4 MiB) alongside max_output_lines; a value's text is
built only up to the room left (display_bounded), never in full to be
thrown away. Past either limit: one marker line, the rest counted.
3. Host results bounded at the boundary#
Every host operation that returns a collection has its own bound and an
explicit error past it (E-MKT-TOO-MANY past 200 000 candles,
E-BT-TOO-MANY past 200 000 trades, E-RS-FEATURE past 64 features), and
what it returns is charged to the program's budget on conversion.
4. Backtest capacity: three levels, refuse, never queue#
- A job holds a slot from before its thread starts until just before its
outcome is published. Slots are counted per execution (4), per
session (6) and per terminal (
JobGate::process(): one per core less one, at most 8), the last shared by every session in the process, so more sessions or programs do not buy more capacity. - A submit past any level is refused with
E-BT-CAPACITY, naming the level. Nothing is queued: a queue would be one more unbounded thing, and "submitted" would stop meaning "running". The program decides — wait, stop one, or give up. - The slot is returned by
Dropon every way a job ends (completed, failed, stopped by the program, by the person, with its execution, the engine panicking — now caught and reportedE-HOST-FAILED— or the thread failing to start), and before the outcome is visible, so a program that waits for a job and submits the next is never refused because of the one it waited for. - The limits are enforced in
t0-host, below every caller: the editor, a program, a test or a future remote client all pass through them. - Finished jobs kept for listing: 50; runs whose trades can still be read: the last 1 000 (journals stay on disk).
- TTT's own strategy workspace runs its backtests outside T0 and is not
counted;
JobGate::try_holdexists for it to join the same limit.
5. Cancellation semantics#
| what is stopped | how | what the program sees | what the person sees |
|---|---|---|---|
the program (Stop program, t0 run interrupted, the terminal closing) |
its cancel token: checked every 1 024 steps, before every host call, and inside cancellable host operations (backtest.wait, market.candles, UI requests) |
nothing more: it ends | the execution ends Cancelled; every job it started is stopped (StoppedByUser) |
one job (Stop job, backtest.cancel) |
that job's flag, checked by the engine before every event | its wait/status report StoppedByUser; the program continues |
the job StoppedByUser, the program still running |
- An execution whose cancellation was requested and that then stops on an
error ends
Cancelled, notFailed: the error is a consequence of the cancellation. - Not interruptible mid-operation:
research.market_state's replay, and a single standard-library call on a large list; cancellation is observed when they return. - CLI:
t0 runinstalls a handler (thectrlccrate: signal handling needsunsafe, which this workspace forbids; its callback runs on its own thread, not in the signal handler). The first SIGINT/SIGTERM cancels the execution → exit code 4 and one JSON result withcategory: "cancelled"; a second ends the process at once (130). Other commands keep the default behaviour.
6. Unsaved programs survive every close#
A pure CloseGuard holds any close over unsaved changes — the T0 window's
own close, the terminal's close (title bar, Alt+F4, the window manager),
and the terminal's self-restart (activation, update) — until the person
answers Save (closes only if the atomic save worked), Discard, or Cancel.
A restart starts the new process only after the answer.
Alternatives#
- A counting global allocator. Counts everything a thread allocates,
but adds
unsafeand a cost to every allocation in the trading terminal for one subsystem; rejected while charges on the two unbounded value kinds suffice. - Checkpoint traversal of reachable values. Misses temporaries on the interpreter's stack and costs time proportional to the heap.
- A bounded job queue. Adds states (queued, starting) the program must handle and a second limit to tune, for a gain programs get by waiting.
Consequences#
Value::String/List/Record/ErrorholdText/Itemsinstead of bareRc<str>/Rc<Vec<Value>>; their observable behaviour is unchanged.xs.push(v)no longer copies the list on every push (it did: the list was read out of its slot before the push), so building a list is linear again.- New stable codes:
E-RT-LIMIT-MEMORY,E-BT-CAPACITY,E-BT-TOO-MANY. Host API 0.1.2 (backtest0.1.2,research0.1.1): compatible patches.
Revisit when#
A workload needs more than the defaults (measure first), or untrusted programs run: then the worker process of ADR-0009 gives hard limits.
Amendment (2026-10-10, later the same day)#
- Bounded where the data is made, not after.
market.candlessizes a range from the dataset's extent and refuses one with room for more than 200 000 candles before reading anything.backtest.tradesreads the run journal as a stream (storage::run::RunJournal::visit, the readerreaditself now uses) and stops past its limit;backtest.trades_pagereads a page and stops one trade past it, withnextsaying where to go on. The research replay already streamed its events. - Cancellation inside long operations. The research replay
(
research_api::market_state_at_until), journal reads and the window-recomputing indicators look at the execution's cancellation periodically, counted in work. - The defaults were measured and kept (benchmarks of the same date): the largest candle result the host allows costs ≈255 MB of the 512 MiB budget; a backtest job is ≈one core and ≈20–25 MB here.