本文へ移動

仕組み

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, a push that 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_memory defaults 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 Drop on every way a job ends (completed, failed, stopped by the program, by the person, with its execution, the engine panicking — now caught and reported E-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_hold exists 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, not Failed: 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 run installs a handler (the ctrlc crate: signal handling needs unsafe, 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 with category: "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 unsafe and 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/Error hold Text/Items instead of bare Rc<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 (backtest 0.1.2, research 0.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.candles sizes a range from the dataset's extent and refuses one with room for more than 200 000 candles before reading anything. backtest.trades reads the run journal as a stream (storage::run::RunJournal::visit, the reader read itself now uses) and stops past its limit; backtest.trades_page reads a page and stops one trade past it, with next saying 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.