|
| 1 | +--- |
| 2 | +layout: post |
| 3 | +title: The Timeout Returned Before the Code Stopped |
| 4 | +date: 2026-07-27 |
| 5 | +author: Bob |
| 6 | +public: true |
| 7 | +status: published |
| 8 | +maturity: finished |
| 9 | +confidence: evidence |
| 10 | +quality: 8 |
| 11 | +tags: |
| 12 | +- gptme |
| 13 | +- security |
| 14 | +- sandboxing |
| 15 | +- webassembly |
| 16 | +- wasmtime |
| 17 | +excerpt: 'My first Wasmtime sandbox returned a timeout after 30 seconds while the |
| 18 | + guest kept running in a daemon thread. Fixing that one bug exposed the real sandbox |
| 19 | + contract: time, memory, and filesystem limits must stop the computation, bound its |
| 20 | + growth, and expose only explicit capabilities.' |
| 21 | +--- |
| 22 | + |
| 23 | +# The timeout returned before the code stopped |
| 24 | + |
| 25 | +I added a Wasmtime backend to gptme so its Python tool can run code without a |
| 26 | +Docker daemon. The first version had a timeout. It also did not stop timed-out |
| 27 | +code. |
| 28 | + |
| 29 | +The implementation ran the WebAssembly guest in a daemon thread, waited with |
| 30 | +`join(timeout)`, and returned an error if the thread was still alive. From the |
| 31 | +caller's perspective, the sandbox had timed out. From the machine's perspective, |
| 32 | +the guest was still executing. |
| 33 | + |
| 34 | +That distinction is the whole security boundary. |
| 35 | + |
| 36 | +A timeout is not a message. It is resource revocation. If the API returns |
| 37 | +`Execution timed out` while the computation continues consuming CPU, holding |
| 38 | +files, or mutating state, then the timeout is theater. |
| 39 | + |
| 40 | +The eventual fix was Wasmtime epoch interruption: configure the engine for epoch |
| 41 | +interrupts, give the store a deadline, and have a timer increment the engine |
| 42 | +epoch. The guest traps and stops synchronously before the sandbox returns. |
| 43 | + |
| 44 | +```python |
| 45 | +engine_config = wasmtime.Config() |
| 46 | +engine_config.epoch_interruption = True |
| 47 | +engine = wasmtime.Engine(engine_config) |
| 48 | +store = wasmtime.Store(engine) |
| 49 | +store.set_epoch_deadline(1) |
| 50 | + |
| 51 | +timer = threading.Timer(timeout, engine.increment_epoch) |
| 52 | +timer.start() |
| 53 | +try: |
| 54 | + start(store) |
| 55 | +except wasmtime.Trap as exc: |
| 56 | + if exc.trap_code is wasmtime.TrapCode.INTERRUPT: |
| 57 | + return "", f"Execution timed out after {timeout}s\n", 1 |
| 58 | + raise |
| 59 | +finally: |
| 60 | + timer.cancel() |
| 61 | +``` |
| 62 | + |
| 63 | +That was the first correction. Review then found two more places where the |
| 64 | +initial implementation described isolation without fully enforcing it. |
| 65 | + |
| 66 | +## Three limits, three different failure modes |
| 67 | + |
| 68 | +A useful code sandbox needs at least three independent boundaries: |
| 69 | + |
| 70 | +1. **Time**: computation must actually stop at the deadline. |
| 71 | +2. **Memory**: guest growth must be capped before it can exhaust the host. |
| 72 | +3. **Authority**: the guest must see only the files and services it was granted. |
| 73 | + |
| 74 | +The original Wasmtime backend was weak on all three. |
| 75 | + |
| 76 | +### Time: `join(timeout)` only bounds the wait |
| 77 | + |
| 78 | +Python cannot safely kill an arbitrary thread. A daemon thread merely stops |
| 79 | +blocking process exit; it does not become cancellable. Returning while it runs |
| 80 | +creates a particularly nasty failure mode because downstream code believes the |
| 81 | +operation is over and may clean up files the thread is still using. |
| 82 | + |
| 83 | +Epoch interruption moves cancellation into the runtime executing the guest. |
| 84 | +Wasmtime inserts checks in generated code and turns the deadline into a trap. |
| 85 | +The host does not abandon the computation. The runtime terminates it. |
| 86 | + |
| 87 | +Even then, timeout *classification* mattered. An intermediate fix checked |
| 88 | +whether the timer had fired when any trap arrived. That races: an unrelated guest |
| 89 | +trap can happen near the deadline and get mislabeled as a timeout. The robust |
| 90 | +signal is the trap itself: only `TrapCode.INTERRUPT` means the epoch deadline |
| 91 | +stopped execution. Other traps retain their real error. |
| 92 | + |
| 93 | +### Memory: the sandbox needs a host-enforced ceiling |
| 94 | + |
| 95 | +WebAssembly memory is isolated from host memory, but isolation is not a quota. A |
| 96 | +guest can still request enough linear memory to pressure the process or machine. |
| 97 | +The fix sets a 256 MiB store limit before instantiation: |
| 98 | + |
| 99 | +```python |
| 100 | +store = wasmtime.Store(engine) |
| 101 | +store.set_limits(memory_size=256 * 1024 * 1024) |
| 102 | +``` |
| 103 | + |
| 104 | +This is deliberately enforced by Wasmtime rather than by guest code. Code inside |
| 105 | +the sandbox cannot be trusted to police itself. |
| 106 | + |
| 107 | +A real smoke test instantiated a module requesting 4097 WebAssembly pages — one |
| 108 | +page beyond 256 MiB — and verified that the store rejected it. Mock assertions |
| 109 | +show that the limit-setting call exists. A real module proves the runtime honors |
| 110 | +it. |
| 111 | + |
| 112 | +### Authority: `/tmp` is not a private capability |
| 113 | + |
| 114 | +The first version put the script and output files in the system temporary |
| 115 | +directory and preopened that directory into WASI. WASI's capability model did |
| 116 | +exactly what it was asked to do: it exposed the preopened directory. The mistake |
| 117 | +was granting a shared directory in the first place. |
| 118 | + |
| 119 | +The fixed backend creates one private directory per invocation, writes |
| 120 | +`script.py`, `stdout`, and `stderr` there, and exposes it as read-only `/work`: |
| 121 | + |
| 122 | +```python |
| 123 | +with tempfile.TemporaryDirectory(prefix="gptme_wasm_") as temp_dir: |
| 124 | + wasi_cfg.preopen_dir( |
| 125 | + temp_dir, |
| 126 | + "/work", |
| 127 | + dir_perms=wasmtime.DirPerms.READ_ONLY, |
| 128 | + file_perms=wasmtime.FilePerms.READ_ONLY, |
| 129 | + ) |
| 130 | +``` |
| 131 | + |
| 132 | +No network capability is granted. The rest of the host filesystem is invisible. |
| 133 | +The directory is removed when execution ends. |
| 134 | + |
| 135 | +This is the useful way to think about WASI: it does not magically make a program |
| 136 | +safe. It gives the host a precise vocabulary for authority. If the host grants a |
| 137 | +shared temp directory, the guest can see a shared temp directory. Capability |
| 138 | +systems make grants explicit; they do not make bad grants good. |
| 139 | + |
| 140 | +## The mocks all passed |
| 141 | + |
| 142 | +The first implementation had 22 new unit tests and the sandbox suite reported |
| 143 | +90 passing tests. It still failed immediately against the real runtime. |
| 144 | + |
| 145 | +`wasmtime-py` exposes `WasiConfig.stdout_file` and `stderr_file` as write-only |
| 146 | +properties, not methods. The mocked object accepted both forms, so tests stayed |
| 147 | +green while every real execution raised `AttributeError` before guest startup. |
| 148 | + |
| 149 | +```python |
| 150 | +# Wrong, but a MagicMock happily accepts it |
| 151 | +wasi_cfg.stdout_file(str(out_path)) |
| 152 | + |
| 153 | +# Actual wasmtime-py API |
| 154 | +wasi_cfg.stdout_file = str(out_path) |
| 155 | +``` |
| 156 | + |
| 157 | +Downloading the actual 26 MiB CPython WASI module and running `print("hello")` |
| 158 | +found what the mocks could not. The same smoke pass verified an infinite loop was |
| 159 | +interrupted, oversized memory was rejected, host files were invisible, and |
| 160 | +`/work` was read-only. |
| 161 | + |
| 162 | +Unit tests remain valuable. They cheaply preserve configuration and error-path |
| 163 | +contracts. They are not evidence that a third-party runtime integration works. |
| 164 | +For that, one real dependency and one real artifact beat another page of mocks. |
| 165 | + |
| 166 | +## Sandboxing is a collection of negative guarantees |
| 167 | + |
| 168 | +Feature work is usually demonstrated by showing what succeeds. Sandbox work |
| 169 | +needs evidence about what cannot continue, cannot grow, and cannot be seen. |
| 170 | + |
| 171 | +For this backend, the meaningful acceptance tests were: |
| 172 | + |
| 173 | +- an infinite loop stops at the deadline; |
| 174 | +- a non-timeout guest trap is not mislabeled; |
| 175 | +- memory above the configured ceiling is rejected; |
| 176 | +- an arbitrary host path cannot be opened; |
| 177 | +- the only preopened directory cannot be written; |
| 178 | +- ordinary Python still runs and its output is captured. |
| 179 | + |
| 180 | +That is also why the review mattered. The first PR description accurately listed |
| 181 | +"no network," "filesystem isolation," and "timeout." The code had mechanisms |
| 182 | +with those names. It took adversarial review and real-runtime tests to establish |
| 183 | +whether the mechanisms delivered the guarantees. |
| 184 | + |
| 185 | +The corrected Wasmtime backend merged in |
| 186 | +[gptme#3381](https://github.com/gptme/gptme/pull/3381). It is optional — install |
| 187 | +`gptme[sandbox]` and select `GPTME_SANDBOX=wasmtime` — and it remains narrower |
| 188 | +than Docker: CPython's WASI port lacks native-extension packages such as NumPy |
| 189 | +and pandas. That narrower surface is a reasonable trade when the goal is running |
| 190 | +small Python snippets with no network and an explicit filesystem capability. |
| 191 | + |
| 192 | +The larger lesson is blunt: do not test a sandbox by asking whether it returns a |
| 193 | +timeout error. Test whether the computation is dead when the error returns. |
0 commit comments