Defect hunt, 2026-09-25
Status: closed 2026-09-29 — all 90 baseline defects fixed, none open (Windows-only validation excluded). Baseline
b9df4aa, linux-x86_64, gcc 13.3.
A session aimed at finding defects nobody had recorded, after the open list in FRICTION.md was closed on 2026-08-18. Host: linux-x86_64, Ubuntu 24.04, gcc 13.3, 4 cores. Baseline: b9df4aa. The findings describe that commit; remediation status below was reconciled on 2026-09-29. Every entry records a defect in that baseline, where it lived, and how it was reproduced; the repro programs themselves lived in a scratch directory that did not survive the session.
Method: eleven finder agents, each on one surface (compiler CLI, generated-source scale, compiler parity, emitted C, runtime, shims, the FFI surface, the network stack, tycho-db, archive and text parsers, and a real 1000-line program). Each finding was then handed to a separate verifier told to refute it: re-run it, check it against FRICTION, the spec and the reference, minimise it, and locate the cause. "verified" below means it survived that; "unverified" means a finder reproduced it but the session ended before a verifier ran; "reproduced" means found and reproduced by hand during the session. These labels in the detailed findings describe baseline evidence; the summary table records current fix status.
Baseline totals: 90 defects -- 3 reproduced, 25 unverified, 62 verified; by severity 2 critical, 18 high, 47 medium, 23 low. Current table status: 90 fixed, none partial, and none open (excluding unavailable Windows-only validation).
What was done
- Build prerequisites installed and recorded.
gcc-multilib(the ilp32 lane refuses to skip),libcurl4-openssl-dev(core:http), andlocaleswithlocale-gen da_DK.UTF-8. Without the last onemake testfailsfloat_lit_localeandfloat_str_locale, exactly as FRICTION #111 says. wine is absent, so its lanes skip.make preflightis green after these. - Baseline
make cirun twice on the untouched tree. The first stopped atmake test, whose only failures were the two locale fixtures. The second, with the locale installed, was red inmake debug-check(D90) and in nothing else that reported; the sweep was stopped during the four-lane join, before the fuzz lanes, so it is not a full verdict. - Parity fuzzers run by hand on the baseline: eq-parity 512/512, unary-parity 30/30, parfor-parity 25/25, type-parity 4608/4608 -- all match their oracles.
fuzz/run_pkg.py 2000was at 1000/2000 with 0 FAIL when it was stopped. - Eleven finder lanes run to completion; eight fully verified (runtime, shims, FFI surface, network, tycho-db, archives, the 1000-line program, CLI). The scale lane is 5 of 10 verified; the parity and codegen lanes returned 10 findings each, but their verifiers had not run when the session ended. Those 25 are the "unverified" rows.
- The 1000-line program was written and run (an HTTP JSON key-value and job-queue service:
core:httpd/json/io/sqlite/signal, five packages, aselectactor, aparallel forworker pool, generics,or_return/or_else). It builds and runs under both compilers once D81 is worked around; its walls are the "Found by the 1000-line program" rows. - Subsequent remediation is recorded in the current tree. Compiler, runtime, corelib, database, server and documentation fixes were added after the baseline session, with focused regressions and FRICTION entries. The audit table records the current disposition. The work remains uncommitted. On 2026-09-29,
make -s testpassed all 1,149 checks;make -s parse-check(including all 665 reject fixtures and whole-tree parity), both corelib runs (46 tests each),make -s db-check,make -s server-check,make surface-check,make -s spec-check,make -s docs-fences,make -s corelib-doc-check, andmake -s friction-checkpassed. The full friction gate reports 202 closed entries, 199 pinned by 178 distinct commands, 3 excused, and 0 unpinned.python3 scripts/check_citations.pyalso passes against the current worktree. - Everything the agents said beyond their headline findings was extracted and recorded: secondary defects and corrections from the verifiers, refuted parts, and the lower-severity defects the finders listed only in their notes. Each of those 255 items was extracted with a verbatim quote of its source and kept only if the quote matched exactly; all 255 did. See the sections after the per-finding detail.
Audit closure and limitations
Closed 2026-09-29: all 90 original findings are fixed, with none partial or open. The main suite, parse, full friction, and citation gates pass as recorded above. The 255 addenda below are historical secondary observations; not every one was independently re-verified or separately dispositioned during remediation, and they are not counted among the 90 findings. Windows-only validation remains excluded from this completion target and unavailable here (no Windows host or Wine). Linux/GDB checks previously ran in an Ubuntu container on a Fedora host.
Findings
Compilers
| id | severity | status | defect |
|---|---|---|---|
| D01 | critical | fixed | Assigning to an element whose container the right-hand side grows through 'inout' wrote into a recycled buffer or lost a map update; fixed in FRICTION #143 with place-store sequencing |
| D02 | high | fixed | tychoc held StructDef*/Variant* across recursive generic resolution and table growth; pointer stability fixed in FRICTION #134, with the associated nested-enum subtree blowup closed in #200 |
| D03 | high | fixed | tychoc1 took cubic memory and time on an elif chain: 800 arms (30 KB source) needed 5.3 GB, 1250 arms were OOM-killed; elif emission is linear in #138/#139 and nested-loop parity is fixed in #202 |
| D04 | high | fixed | tychoc1 lowered '/' and '%' to bare C operators for fixed-width integers and element-wise array arithmetic; guarded in FRICTION #135 |
| D05 | high | fixed | An empty literal '[]' was accepted for '[N]T' or 'vector[N]T'; rejected in FRICTION #136 |
| D06 | high | fixed | Out-of-range numeric literals wrapped silently in destination-coercion paths; fit checks added in FRICTION #142 |
| D07 | high | fixed | tychoc1 dropped const lengths for fixed arrays/vectors; resolved and validated in FRICTION #137 |
| D08 | high | fixed | tychoc1 emitted unguarded C '/' and '%' for sized integers and array division; guarded in FRICTION #135 |
| D09 | high | fixed | 'total := total(xs)' emitted invalid C when a local shared its initializer function's name; fixed in FRICTION #140 |
| D10 | high | fixed | tychoc1 dispatched shadowed builtins inconsistently with its resolver warning; fixed in FRICTION #140 |
| D11 | medium | fixed | tychoc read_file() overflowed on non-seekable source paths; fixed and pinned in FRICTION #141 |
| D12 | medium | fixed | Both compilers now escape quotes, backslashes and control bytes in -g #line paths; pinned in FRICTION #144 |
| D13 | medium | fixed | Nested generic method chains and constructors now reuse inferred argument/receiver types rather than recursively re-emitting or re-resolving subtrees; pinned by FRICTION #200 (tests/emitter_nested_calls.sh) |
| D14 | medium | fixed | An elif chain was lowered to nested C blocks; emitted iteratively and pinned in FRICTION #139 |
| D15 | medium | fixed | tychoc1 program_in retained growing struct-definition prefixes quadratically; fixed and stress-tested in FRICTION #145 |
| D16 | medium | fixed | Flat integer + chains remain iterative with the 2000-term diagnostic; other left-deep binary spines now get a located 1024-depth diagnostic in both compilers, and the self-hosted typechecker guards recursive expression trees (FRICTION #157) |
| D17 | medium | fixed | Flat simple-int sums and interpolated-string joins now emit at constant bracket depth; pinned under clang in FRICTION #146 |
| D18 | medium | fixed | Large constant scalar array literals now use a block-scope static table plus memcpy in both emitters; nonconstant literals retain element-wise ownership handling; 15,625 elements compile under gcc -O3 in tests/array_literal_scale.sh |
| D19 | medium | fixed | F-string C joins now assemble pieces linearly; lambdas append captures/parameters and restore binding-table length marks rather than snapshotting every enclosing local table; 4,000 holes and 8,000 capturing lambdas stay below 1.2 GiB in tests/emitter_memory_scale.sh |
| D20 | medium | fixed | tychoc1 emitted a synthesized tuple type rather than its destination type when tuple elements adapted; fixed and covered across contexts in FRICTION #147 |
| D21 | medium | fixed | Packed-field inout uses an aligned local and memcpy to/from the byte-offset field address in both emitters, including FFI calls; direct, array-element and custom-subscript paths run under UBSan in tests/packed_inout.ty, and extern calls in tests/packed_inout_ffi.sh |
| D22 | medium | fixed | Fixed-width float conversions now use the shared total float-to-u64 wrapping helper, matching native behavior; FRICTION #150 covers finite out-of-int64, negative, and NaN inputs |
| D23 | medium | fixed | tychoc1's if/elif memory use grew super-linearly; the flat AST fix is recorded in FRICTION #138 |
| D24 | medium | fixed | F-string holes now trim surrounding whitespace, reject trailing tokens and skip char literals while finding the close brace; pinned by fstring_hole_whitespace and two reject fixtures |
| D25 | medium | fixed | tychoc1 typechecked but could not emit documented pow(x, y) or SoA for-in; both lowerings are covered by tests/emit_pow_soa_iter.ty |
| D26 | medium | fixed | Compiler-generated AST temporaries use a reserved 'Ztycho_' identifier namespace rejected by the lexer; collision cases for for-in, compound place/multi-assign, and '_mx36_0_0' are pinned in FRICTION #154 |
| D27 | medium | fixed | Fixed with D22: self-hosted fixed-width float conversions use 'tycho_f2u64' rather than the trapping 'tycho_f2i'; pinned in FRICTION #150 |
| D28 | medium | fixed | Enum payload members and payload-struct typedefs use prefixed C-only names, keeping C keywords/macros and user enum names out of the generated namespace; tested in FRICTION #156 |
| D29 | medium | fixed | Float hash bits are canonicalized for both scalar composite leaves and float arrays, so +0.0 and -0.0 hash equally; nested-key overwrite is pinned in FRICTION #151 |
| D30 | medium | fixed | Self-hosted composite-key validation now matches native leaf types and rejects sized-int/f32 fields before emission; covered by two reject fixtures |
| D31 | low | fixed | Procedure, constant, variant and generic registries now use append-aware indexes, and both compilers cache source-line excerpts; stress-tested in FRICTION #198/#201 (tests/proc_signature_scale.sh, tests/name_registry_scale.sh) |
| D32 | low | fixed | --symbols now runs resolution and typechecking before printing, exits nonzero on invalid input, and emits no partial index (FRICTION #197) |
| D33 | low | fixed | Both CLIs reject an explicitly empty -o before binary or --emit-c output, so neither creates a stray .c file (FRICTION #197) |
| D34 | low | fixed | Both CLIs reject separated -L/-I without a non-option directory argument; a following linker flag is no longer swallowed as a path (FRICTION #197) |
| D35 | low | fixed | Native resolver recursion depth is reset at statement/procedure longjmp recovery boundaries; a generated 2105-error check confirms neither compiler fabricates depth diagnostics (FRICTION #155) |
| D36 | low | fixed | Variant ownership now consults the declared package prefix instead of splitting arbitrary type names at '__'; generic instance C names gain a suffix when they collide with source aggregate names; covered in FRICTION #158 |
| D37 | low | fixed | Shift lowering now preserves unsigned count bits before width checks, so u64 counts ≥ 2^63 yield zero rather than a false negative-count abort; FRICTION #153 |
| D38 | low | fixed | Native generic parameter-pattern diagnostics now use nominal_name(); tests/package_diagnostic_name.sh asserts both compilers print source-spelled (not mangled) function names |
Runtime and emitted code
| id | severity | status | defect |
|---|---|---|---|
| D39 | high | fixed | Array reserve checks multiplication before allocation; map reserve bounds capacity before doubling/index sizing, and both abort before wrapped allocations (FRICTION #178, tests/audit_runtime_guards.sh) |
| D40 | medium | fixed | The self-hosted emitter now lowers the documented pow(x, y) builtin; tests/emit_pow_soa_iter.ty covers it (FRICTION #148) |
| D41 | medium | fixed | Channel capacity is checked against the cell-array allocation size before multiplication; the runtime-guard gate checks overflow in both compilers (FRICTION #178) |
corelib shims
| id | severity | status | defect |
|---|---|---|---|
| D42 | high | fixed | Regex subjects are capped at 4,096 bytes, bounding the pathological unanchored search; oversize input is refused and pinned by the runtime-guard gate (FRICTION #178) |
| D43 | high | fixed | Crypto hex inputs now carry explicit lengths or reject embedded NULs before C-string parsing; crypto regression cases pin the surface (FRICTION #164) |
| D44 | high | fixed | HMAC, signature verification and AEAD paths reject NUL-truncated inputs under the same binary-safe crypto boundary fix (FRICTION #164) |
| D45 | medium | fixed | io.copy detects identical files by device/inode before opening the destination; corelib I/O tests cover same-path and alias cases |
| D46 | medium | fixed | SQLite path and SQL entry points reject embedded NULs before crossing the C-string ABI; corelib tests verify the database/statement is not changed (FRICTION #164) |
| D47 | medium | fixed | HTTP request construction rejects control characters in Content-Type; tests/http_header_guard.sh pins header injection rejection (FRICTION #178) |
| D48 | medium | fixed | Regex group indices are checked against the compiled capture count before allocating or reading capture slots (FRICTION #178) |
| D49 | medium | fixed | Stream lines and child-process output cross the FFI as length-carrying bytes, preserving embedded NULs (FRICTION #164) |
| D50 | low | fixed | Inflate growth now reaches the inclusive configured cap, probes past it, and distinguishes over-limit from truncated input; a reduced-cap C gate verifies both boundaries (FRICTION #165) |
| D51 | low | fixed | POSIX TZ numeric fields and rule ranges are bounded before conversion; oversized-hour/minute/second and long-digit inputs are pinned (FRICTION #166) |
| D52 | low | fixed | Both compilers reject aggregate-return handle destructors whose C ABI needs hidden out-parameters; reject fixtures cover bytes and arrays (FRICTION #167) |
| D53 | low | fixed | FFI docs distinguish C int (i32) from Tycho int and describe to_i32 as narrowing (FRICTION #181) |
Network (core:net, core:http, core:httpd, server/)
| id | severity | status | defect |
|---|---|---|---|
| D54 | high | fixed | Request framing rejects every Transfer-Encoding field and empty Content-Length, distinguishing absent from present-empty headers (FRICTION #180) |
| D55 | medium | fixed | HTTP Content-Type control characters are rejected before header serialization (FRICTION #178) |
| D56 | medium | fixed | tls.connect_timeout and tls.set_timeout_ms are public, bounded to signed 32-bit milliseconds, and listed in surface.lock (FRICTION #179) |
| D57 | low | fixed | parse_request rejects invalid field names/values, obsolete folding, extra request-line tokens and unsupported versions (FRICTION #180) |
| D58 | low | fixed | Range digit limits count significant digits, not leading zeroes; padded positions and suffix lengths are pinned in server/run.sh (FRICTION #180) |
| D59 | low | fixed | The net shim validates descriptor range before narrowing to the platform int type; corelib net tests pin large descriptors (FRICTION #178) |
| D60 | low | fixed | Both package suggestion paths exclude _-prefixed private symbols; a package reject fixture pins the no-suggestion diagnostic (FRICTION #196) |
Archive and text parsers
| id | severity | status | defect |
|---|---|---|---|
| D61 | high | fixed | TOML parent tables now merge prior implicit subtables, and nested array-of-table headers attach to the latest element; regression cases preserve both paths (FRICTION #182) |
| D62 | medium | fixed | resolve_under now detects and refuses dangling symlink components instead of climbing past them; ordinary missing leaves still return for 404 (FRICTION #195) |
| D63 | medium | fixed | TOML parsing now bounds input to 64 KiB, entries/headers to 512 and nesting to 128, including arrays and dotted paths; boundary refusals are pinned (FRICTION #183) |
| D64 | medium | fixed | zip.create_checked validates classic ZIP names/counts/sizes/offsets before writing; legacy create is fail-fast, not truncating (FRICTION #184) |
| D65 | medium | fixed | All zlib entry points reject lengths that exceed uInt before narrowing (FRICTION #165) |
| D66 | medium | fixed | TOML uses a quote-aware dotted-key scanner for assignments/headers, decodes quoted basic keys, rejects trailing literal-string text and surrogate escapes (FRICTION #185) |
| D67 | medium | fixed | JSON object parsing indexes keys with a per-object hash map while preserving last-wins-at-first-position duplicates; 20,000-member parse is pinned in corelib (FRICTION #186) |
| D68 | medium | fixed | Inline rendering caches found ]/) positions, so unmatched link/image openers reuse terminators instead of rescanning; 20,000-opener fixtures are pinned (FRICTION #187) |
| D69 | medium | fixed | Meta carries its local offset and extract_entry enables one-directory-pass iteration; EOCD search is limited to the legal 65,557-byte suffix and rejects trailing junk (FRICTION #188) |
| D70 | medium | fixed | Gzip decompression consumes concatenated members and rejects trailing non-member data (FRICTION #165) |
tools/tycho-db
| id | severity | status | defect |
|---|---|---|---|
| D71 | high | fixed | Checkpoints write/sync a sibling temp file, atomically rename it, sync the directory, then rewind the WAL; a file-size-limit regression preserves and reopens the old store (FRICTION #168) |
| D72 | high | fixed | The self-hosted emitter now discovers bytes and nested match/select accumulators; a 50,000-iteration two-compiler gate runs under an RSS limit (FRICTION #177) |
| D73 | medium | fixed | Sorted-array shifts were replaced with a type-tagged hash index; 20,000 reverse-order inserts and a reopen/rebuild stay under 192 MiB (FRICTION #175) |
| D74 | medium | fixed | CREATE/INSERT validate, append to the WAL, then apply; the NotWritten regression proves later statements cannot observe a refused mutation (FRICTION #169) |
| D75 | medium | fixed | wal.open_db acquires a process-lifetime exclusive .lock before reading the store; a CLI competing with the server is refused (FRICTION #170) |
| D76 | medium | fixed | Store decoding validates row shape/types, integer encodings, tags, truncation and trailing bytes; corrupted fixtures fail closed with no stdout (FRICTION #171) |
| D77 | low | fixed | A corrupt store is no longer rebuilt from a zero-length WAL; the table's missing-store half was refuted in the addenda and is not a defect (FRICTION #172) |
| D78 | low | fixed | Server lines have an absolute one-second deadline and sessions a 60-second cap; an idle-peer/second-client test verifies the daemon resumes service (FRICTION #173) |
| D79 | low | fixed | SELECT and EXPLAIN validate indexed-key types before probing; the addenda's separate WHERE-column claim was refuted (FRICTION #174) |
Found by the 1000-line program
| id | severity | status | defect |
|---|---|---|---|
| D80 | critical | fixed | The self-hosted emitter now handles bytes and accumulators in match/select scopes; large 50,000-iteration fixtures run under a memory limit with both compilers (FRICTION #177) |
| D81 | high | fixed | The self-hosted receive lowering allocates the Option and payload copy in cs.arena; direct-return, bound and or_else cases survive arena churn under both compilers (FRICTION #176) |
| D82 | medium | fixed | Both emitters copy map replacements into the owner and recursively recycle nested arrays, maps, structs, enums, tuples, options/results, SOA values and captured closures; composite-key/value deletion also reclaims owned payloads (FRICTION #193, tests/map_overwrite_scale.sh) |
| D83 | medium | fixed | Qualified generic-enum construction now resolves templates within the named package and supports inferred payload or explicit type arguments; a two-compiler package fixture covers both forms (FRICTION #191) |
| D84 | medium | fixed | [string] writes copy/recycle old values and variadic packs/arguments use loop scratch; a two-million-iteration combined regression runs below 96 MiB under both compilers (FRICTION #192) |
| D85 | medium | fixed | SQLite path and SQL functions reject embedded NULs before the C-string boundary (FRICTION #164) |
| D86 | low | fixed | The enum reference now states bare None equality is ungrounded; tychoc1 rejects bare Option/Result comparisons during typechecking with a located diagnostic and has three reject fixtures (FRICTION #189) |
| D87 | low | fixed | Self-hosted builtin-shadow advice now uses the declaration's package prefix; a subpackage len warning fixture pins its qualified-call suggestion (FRICTION #190) |
| D88 | low | fixed | BodyTooLarge now separates a framed entity over budget from an incomplete oversized header; server and tycho-kvsrv return 413 vs 431 with reason text, pinned by corelib and wire tests (FRICTION #194) |
| D89 | low | fixed | The SQLite example and stale corelib append/path/NUL documentation are corrected; docs-fence and corelib-reference gates pin them (FRICTION #181) |
Gates
| id | severity | status | defect |
|---|---|---|---|
| D90 | medium | fixed | tools/tycho-debug/run.sh accepts both GDB 15's No line 999 in file and GDB 17's No compiled code for line 999; all seven make debug-check legs pass on Fedora/GDB 17.2, including idle Ctrl-C |
Detail
Line numbers are as of b9df4aa and are written as prose, not as live citations.
D01. Assigning to an element whose container the right-hand side grows through 'inout' writes into a buffer the arena has already recycled: free-list corruption and SIGSEGV (tychoc1+gcc, tychoc+clang), or a lost update (map, both compilers+gcc)
critical, unverified; compilers: both.
- Where: Place-store lowering. tychoc1: compiler/emit/emit.ty emits '(*tycho_arr_int_ptr(&(h_xs), 0LL)) = h_grow(_parent, &(h_xs), &_scope);'. Both compilers emit '(*tycho_map_si_slotptr(&_scope, &(h_m), TYCHO_LIT("x"))) = h_fill(...)' for a map place. tychoc emits ...
- Observed: f_place_inout_uaf/main.ty: fn grow(a: inout [int]) -> int: push(a, 0) return 7 fn main(): xs := [1, 2, 3] xs[0] = grow(&xs) ys := [4, 5, 6] zs := [7, 8, 9] println(str(xs[0]) + " " + str(ys[0]) + " " + str(zs[0])) == tychoc -> '7 4 7' rc=0 == tychoc1 -> 'Segmentation fault $S/f_place_inout_uaf/$c' rc=139 gdb: '760 FreeNode *fn = a->bkt[k]; a->bkt[k] = fn->next;' ... '#3 tycho_arr_int_with_cap ...
- Mechanism: The store is emitted as '*ADDR(place) = RHS' or 'set(&*ADDR(outer), i, RHS)'. The address computation and the RHS call are unsequenced (indeterminately sequenced) C operands or arguments. gcc computes 'tycho_arr_int_ptr'/'slotptr' first, and clang computes set()'s first argument first. The RHS's inout push reallocates and calls arena_recycle on the old block, so the store writes the Tycho value into a FreeNode's 'next' field. The next same-size allocation then pops that value as a pointer. With a map, the table is rehashed and the write goes to the dead table, so the update is lost.
- Suggested fix: In both emitters, when a place-store's RHS contains any call (or at least an inout argument rooted at the place's root variable), evaluate the index legs into temps first (as §13.4 requires), then the RHS into a temp, and only then compute the element address and store: '{ tycho_int _i = idx; T _v = rhs; *tycho_arr_int_ptr(&xs, _i) = _v; }'. The same applies to map slotptr and nested ptr chains. ...
D02. tychoc heap-use-after-free: a StructDef*/Variant* pointer is held across resolve_expr while a nested generic instantiation reallocs g_structs/g_enums. SIGSEGV on a 195-byte valid program
high, verified; compilers: tychoc.
- Where: src/tychoc.c resolve_expr_inner. Generic-struct constructor: 'StructDef *t = &g_structs[sid]' at 7289, then resolve_expr(e->args[i]) at 7295, then 't->fields[i].type' read at 7296. Generic-enum constructor: 'EnumDef *gt'/'Variant *gv' at 7325-7326, resolve_expr at 7337, read at 7338/7341. The ...
- Observed: Frozen baseline b9df4aa, release tychoc: fx_uaf_box17: "Segmentation fault ./tychoc .../fx_uaf_box17/main.ty -o ..." tychoc rc=139; tychoc1 rc=0, prints 1 min/v16 (depth 16): tychoc rc=0. min/v17 (depth 17): "Segmentation fault", rc=139. tychoc1 builds both. min/e12..e16: release rc=0, ASan clean. min/e17: release rc=0, ASan heap-use-after-free. ASan tychoc: fx_uaf_box17 / fx_uaf_box2: "ERROR: ...
- Mechanism: src/tychoc.c@resolve_expr_inner has two constructor blocks with the same flaw. Generic struct constructor (7289-7305). - It takes 'StructDef *t = &g_structs[sid]' before the argument loop. - It then calls 'resolve_expr(e->args[i])'. - When that argument is itself a generic constructor, the call reaches struct_instantiate, and 'TBL_ENSURE(g_structs, ...)' at 2346 reallocs the table once g_nstructs hits capacity (16, 32, 64, ...). - 't->fields[i].type' at 7296 then reads freed memory, and so do 't->ntyparams'/'t->typarams' at 7300-7301. Generic enum constructor (7324-7341). - 'EnumDef *gt = &g_enums[eid]' and 'Variant *gv = >->variants[evi]' are held across 'resolve_expr' at 7337. - ...
- Suggested fix: Hold the index, not the pointer. Re-read g_structs[sid] and g_enums[eid].variants[evi] after each resolve_expr(e->args[i]), or resolve all args into a local Type array first and only then take the pointer. Add fixtures: Box nested 17 deep, and Box(Box(7)) with 15 filler structs under the ASan lane.
D03. tychoc1 takes cubic memory and time on an elif chain: 800 arms (30 KB source) need 5.3 GB, 1250 arms are OOM-killed
high, verified; compilers: tychoc1. Current disposition: the elif path is linear in FRICTION #138/#139; the native/self-hosted nested-loop depth mismatch is fixed in #202. The separate compile-cost observation for 250 nested blocks remains a bounded (256-level) syntax/resource limit, not a constant-cost guarantee.
- Where: compiler/emit/emit.ty _stmt, ast.IfS arm (5951-5957): 'out := ind + "if (" + cc + ") {\n" + _block(&cs, b, ind + " ") + ind + "}"' plus the else branch. _block (4977-4988): 'out = out + _stmt(...)'.
- Observed: Original repro, capped at 4 GB: tychoc1: "tycho: out of memory", real 4.77s, rc=1 (o1.c holds only the streamed preamble) tychoc: wrote o0.c, real 0.039s, rc=0 Uncapped, fx_elif800 shape: n=100 tychoc1 0.05s 14MB | n=200 0.18s 89MB | n=400 0.90s 684MB | n=800 6.51s 5355MB tychoc 10MB at every n Minimized chain ('pass' bodies): n=200 0.13s 72MB | n=400 0.57s 523MB | n=800 3.51s 4005MB n=1250 ...
- Mechanism: Two causes combine. 1. The emitter re-copies the whole remaining chain at every level (compiler/emit/emit.ty@_stmt, ast.IfS arm, lines 5951-5957). An elif chain parses to IfS(else=[IfS(...)]), and each level runs 'out := ind + "if (" + cc + ") {\n" + _block(...) + ind + "}"' and then 'out = out + " else {\n" + _block(&cs, e, ind + " ") + ind + "}"'. In tychoc1's own emitted C this is tycho_str_concat / tycho_str_concat5 into _scope, followed by a final concat into _parent. _block (emit.ty (line 4977 at b9df4aa)) then appends each child's result again. Level k indents 4k spaces, so the text is O(n^2). Every level copies its entire subtree several times, so O(n^3) bytes are copied in total: ...
- Suggested fix: In _stmt, walk an else-branch that is a single IfS iteratively and emit '} else if (c) {' at one indentation level. Build block text by pushing pieces into a [string] and joining once, or give _block a real in-place accumulator.
D04. tychoc1 lowers '/' and '%' to bare C operators for every fixed-width integer and for element-wise array arithmetic. A zero divisor, or (-2^63) / -1, kills the process with SIGFPE (a literal 0 gives SIGILL) instead of the spec'd clean abort.
high, unverified; compilers: tychoc1.
- Where: compiler/emit/emit.ty, binary-op lowering (~3601-3613): only 'op == "/" and at == "int" and bt == "int"' goes through tycho_idiv/tycho_imod, and every sized type falls through to '(" + a + " " + op + " " + b + ")'. The element-wise array lane at emit.ty (line 3582 at b9df4aa) emits '(lhs op rhs)' ...
- Observed: [F01_sized_div tychoc1] Floating point exception exit=136 [F01_sized_div tychoc] tycho: division by zero exit=1 [F01b_ew_div tychoc1] Floating point exception exit=136 (xs / z, xs: [int]) [F01b_ew_div tychoc] tycho: division by zero exit=1 [F01c_ew_lit0 tychoc1] Illegal instruction exit=132 (xs / 0 compiled) [F01c_ew_lit0 tychoc] main.ty (line 3 at b9df4aa): error: division by zero [F01d_i64_min ...
- Mechanism: tychoc1's emitter routes only int/int scalars through the runtime guards. src/tychoc.c uses tycho_idiv/tycho_udiv for every integer kind and for each element-wise lane. tychoc1's type checker also places the literal-zero rule after the element-wise early return.
- Suggested fix: In emit.ty's scalar arm, lower '/' and '%' on every sized integer through the guarded helpers src/tychoc.c already uses, then narrow the result. Use the same helper for the element-wise lane (emit.ty (line 3582 at b9df4aa)) and the vector lane. In tcheck.ty, move the '_is_zerolit(k[1])' check above the element-wise arm. Pin with tests/abort fixtures for u32 '/', i64 MIN%-1 and '[int] / z' ...
D05. An empty literal '[]' is accepted for a fixed-size '[N]T' or 'vector[N]T'. tychoc emits an uninitialised C aggregate (reads of uninitialised stack, SIGSEGV with string elements), and tychoc1 builds a length-0 "fixed" array.
high, unverified; compilers: both.
- Where: src/tychoc.c (line 8225 at b9df4aa), the fixed-arity check is guarded by 'e->nargs > 0', so '[]' skips it, and codegen then emits '({ TychoArrC0 _lN; _lN; })'. In tychoc1, compiler/types/tcheck.ty (line 1563 at b9df4aa) 'ast.EmptyArr' returns 'want' for any array kind, fixed and vector included, ...
- Observed: F02_empty_fixed ('names: [2]string = []' then 'names[1]'): tychoc : Segmentation fault, exit=139. Under ASan+UBSan: 'e0.c (line 1338 at b9df4aa):62: runtime error: applying non-zero offset 18446744073709551608 to null pointer' tychoc1: tycho: index 1 out of bounds (len 0), exit=1 F02b ('a: [8]int = []', summed after another frame dirtied the stack), tychoc C at -O0: 8028 376188606918476 <- ...
- Mechanism: The arity check assumes a non-empty literal. The empty-literal path takes the fixed type as its own type, and nothing then fills the N slots.
- Suggested fix: Drop the 'e->nargs > 0' guard for fixed and vector destinations in src/tychoc.c and apply the same count check in tcheck.ty's EmptyArr arm when 'want' is fix(...) or vec(...). Pin with tests/reject/fixed_array_empty_literal.ty for '[2]string = []', and a vector[4]float variant.
D06. Out-of-range numeric literals wrap silently instead of being refused. tychoc1 does this in variadic, UFCS, lambda-body, m.get default, subscript-assignment, value-match and f32 vector/array routes. Both compilers do it for the element-wise broadcast scalar on a sized-int array.
high, unverified; compilers: both.
- Where: tychoc1: the sized-literal fit check ('does not fit', compiler/types/tcheck.ty ~1071) and the f32 range check do not run on variadic packing, method-call (UFCS) arguments, 'c.send(x)', 'm.get(k, d)', lambda expression bodies with a declared return, subscript place assignment, value-match arms under ...
- Observed: F03_lit_wrap: bytes_of(1, 300, -1) with '...u8'; one.pick(300); 'fn() -> u8: 300'; m.get("k", 256) tychoc : main.ty (line 9 at b9df4aa): error: 300 does not fit u8 (its range is 0..255) -- write to_u8(x) to convert with wraparound tychoc1: cc warning 'unsigned conversion from 'long long int' to 'unsigned char' changes value from '300' to '44' [-Woverflow]', then output: [1, 44, 255] 44 44 0 ...
- Mechanism: The literal fit check is attached to specific adaptation sites and was never wired into the sites listed above. In the broadcast arm, the int-to-sized adaptation sets the type directly instead of calling sized_lit().
- Suggested fix: src/tychoc.c (line 8109 at b9df4aa): call 'sized_lit(sc, et)' instead of assigning sc->type. In tychoc1, run the fit/f32-range check wherever a literal is coerced to a sized or f32 destination: variadic packing, UFCS args, lambda return, m.get default, send, subscript store, value-match arms. Better, do it in one coercion helper that every site calls. Pin with one reject fixture per route, ...
D07. tychoc1 drops the length of '[N]T' when N is an int const: wrong-arity literals, 'push' onto a fixed array, zero or negative N, and mismatched fixed-array parameters all compile, and 'len' is wrong. The same const 'vector[N]T' fails with an internal error or invalid C.
high, unverified; compilers: tychoc1.
- Where: compiler/types/tcheck.ty (line 592 at b9df4aa) '_fixsize', which returns "?" when the size names a const ('if sz in cs.consts: return "?"'). Every length-keyed check tests '_capn(...) >= 0' and therefore turns off, including arity (tcheck.ty (line 1495 at b9df4aa)), push/pop refusal and fixed-type ...
- Observed: F04_const_len ('const N = 3'; 'a: [N]int = [1, 2, 3, 4, 5]'; push(a, 6); pass a to fn width(row: [2]int)): tychoc : main.ty (line 8 at b9df4aa): error: a fixed-size array of length 3 needs 3 elements, got 5 tychoc1: 6 [1, 2, 3, 4, 5, 6] 6 F04b_const_vec ('const N = 4'; 'v: vector[N]float = [1.0, 2.0, 3.0, 4.0]'): tychoc : [2.0, 4.0, 6.0, 8.0] tychoc1: tychoc: a vector[0] literal needs 0 elements ...
- Mechanism: '_fixsize' treats "is a const" as "size unknown" instead of looking up the const's value, which '_bnd_size' already does for bounded capacities.
- Suggested fix: Make '_fixsize' and the vector-count resolver mirror '_bnd_size': look up cs.constv, refuse a value of 0 or below, and return the decimal value so every downstream '_capn' check and the emitter see the real N. Pin with fixtures for '[N]int' arity, push, zero or negative N, and a 'vector[N]float' literal plus parameter under tychoc1.
D08. tychoc1 emits unguarded C '/' and '%' for every sized integer type and for element-wise array division, so division by zero is SIGFPE/SIGILL instead of 'tycho: division by zero', and i32/i64/[int] MIN/-1 trap
high, unverified; compilers: tychoc1.
- Where: compiler/emit/emit.ty binary-op lowering (~line 3601): only 'op == "/" and at == "int" and bt == "int"' goes to tycho_idiv/tycho_imod. Sized types fall through to '"(" + _cty(&cs, rt) + ")((" + a + " " + op + " " + b + "))"'. The element-wise array loop (~line 3577) emits '(lhs op rhs)' raw even ...
- Observed: f_u32div: 'a: u32 = 7' / 'b := z()' (z returns u32 0) / 'println(str(a / b))' tychoc: 'tycho: division by zero' / 'dividing' / 'tychoc rc=1' tychoc1: 'Illegal instruction $S/f_u32div/b' / 'tychoc1 rc=132' (the preceding println is lost) Sweep across u8 u16 u32 u64 i8 i16 i32 i64, '/' and '%': every one gives 'tychoc rc=1 [tycho: division by zero]' or '[tycho: modulo by zero]', and 'tychoc1 rc=132 ...
- Mechanism: The guard is keyed on the literal type string "int". Every sized type, and the element-wise lane for all element types, take the raw C operator path.
- Suggested fix: Route sized unsigned '/' and '%' through tycho_udiv/tycho_umod and sized signed ones through tycho_idiv/tycho_imod with the result cast, as src/tychoc.c does. Apply the same to the element-wise lane, as the vector path at emit.ty (line 3529-3532 at b9df4aa) already does for int. Add tests/abort fixtures for a sized type and for an element-wise zero element.
D09. 'total := total(xs)', a local named after the same-package function that initializes it, is accepted by both compilers and emits C that does not compile
high, unverified; compilers: both.
- Where: Local-declaration lowering in both emitters. Locals and same-package functions share the 'h_' prefix ('tycho_int h_total; { ... h_total = h_total(...); }'), so the C local is in scope in its own initializer and shadows the function. tychoc also emits a C call to 'h_NAME' whenever a local or ...
- Observed: f_selfname/main.ty: 'fn total(xs: [int]) -> int:' ... 'fn main():' / ' total := total([1, 2, 3])' / ' println(str(total))' tychoc: 'f_selfname/a.c (line 3239 at b9df4aa):61: error: called object 'h_total' is not a function or function pointer' / ' 3239 | tycho_int h_total; { Arena _t = arena_new(0); h_total = h_total(&_t, ({ TychoArrInt _l1 = ...' tychoc1: 'f_selfname/b.c (line 3233 at ...
- Mechanism: C scoping differs from Tycho scoping. The emitted declaration puts 'h_total' (the local) in scope before the initializer's call to 'h_total' (the function), and the two share one C identifier namespace.
- Suggested fix: Give functions a prefix distinct from locals and parameters (e.g. 'hf_NAME' versus 'h_NAME'), or evaluate the initializer into a temp before declaring the local. In tychoc, run the vars_find check ("has type %s, not a procedure") even when a Sig of that name exists, to match tychoc1.
D10. tychoc1 calls a user function that shadows a builtin (keys, map_get, to_u8, ...) where tychoc -- and tychoc1's own warning -- say the builtin wins: a silent miscompile
high, reproduced.
- Observed: fn keys(m: [string: int]) -> [string]: return ["zzz"], then println(keys(["a": 1])[0]): tychoc prints a, tychoc1 prints zzz. With fn keys(x: int) -> int, tychoc1 dies in emit with 'len() of unsupported type int'.
- Mechanism: compiler/emit/emit.ty@_shadowable is a shorter list than compiler/types/resolve.ty@_shadowable and src/tychoc.c@shadows_builtin, and the emitter dispatches to the user fn for any name missing from its list.
- Suggested fix: One shared list. Also: for floor/ncpu both compilers say 'is already defined' but disagree on which names get that error.
D11. tychoc read_file() aborts (heap buffer overflow) on any non-seekable source path (pipe, FIFO, process substitution, piped /dev/stdin)
medium, verified; compilers: tychoc.
- Where: src/tychoc.c (line 14820 at b9df4aa) read_file() — unchecked ftell(); tychoc1 handles it in corelib/io/io.c (fstat + growing fread loop)
- Observed: $ true | ./tychoc /dev/stdin -> * buffer overflow detected *: terminated / Aborted, exit=134 $ true | ./tychoc1 /dev/stdin -> /dev/stdin: error: no 'main' procedure, exit=1 $ printf 'fn main():\n println("hi")\n' | ./tychoc /dev/stdin --emit-c -> * buffer overflow detected *, exit=134 $ same | ./tychoc1 /dev/stdin --emit-c -> exit=0, emitted C contains "hi" $ ./tychoc <(printf ...) ...
- Mechanism: src/tychoc.c@read_file (lines 14820-14837). It calls fseek(f,0,SEEK_END) and 'long sz = ftell(f)' without checking either return value. On a pipe, FIFO or process substitution, ftell returns -1, so 'xmalloc((size_t)sz + 1)' becomes malloc(0), and 'fread(buf, 1, (size_t)sz, f)' asks for SIZE_MAX bytes. With glibc fortify (the -O2 default here), __fread_chk sees a request larger than the 0-byte object and calls __chk_fail, giving SIGABRT with the misleading "buffer overflow detected" message. Without fortify, Linux returns EFAULT and tychoc prints "tychoc: read error". Other libcs (macOS, Windows CRT) may actually write the bytes past the allocation. When st_size is 0 but the file has content ...
- Suggested fix: Detect non-regular/unseekable input: fstat() the fd and, when !S_ISREG(mode) or ftell()<0, read with a growing buffer loop (or reject with a clean error). At minimum, check 'if (sz < 0) { fprintf(stderr,"tychoc: cannot determine size of %s ..."); exit(1); }' before the malloc — mirroring tychoc1's io shim.
D12. -g emitted unescaped source paths in #line directives and DWARF, corrupting debug paths or producing malformed C
medium, verified; compilers: both. Current disposition: fixed in FRICTION #144; both emitters escape quotes, backslashes, newlines, tabs, and other control bytes, pinned by tests/debug_path.sh.
- Where: compiler/emit/*.ty (dbgfile emission: 'out + "#line " + str(...) + " \"" + cs.dbgfile + "\"\n"') — no C-string escaping, unlike src/tychoc.c (line 15244 at b9df4aa) c_escape_path()
- Observed: == ./tychoc #line 2 ".../verify/cli-2/min/b\\tx/x.ty" built .../verify/cli-2/min/bin == ./tychoc1 #line 2 ".../verify/cli-2/min/b\tx/x.ty" .../verify/cli-2/min/bin.c (line 3220 at b9df4aa):9: error: \x used with no following hex digits tychoc: C compilation failed DWARF (objdump --dwarf=rawline | cat -A): tychoc -> ".../cli-2/bs\tx" tychoc1 -> ".../cli-2/bs^Ix" Dir q"d, tychoc1: bin.c (line 3220 ...
- Mechanism: The driver (compiler/driver/driver.ty (line 279 at b9df4aa) and :439) sets 'dbgf = p.files[0].path' and passes it through unchanged to emit.program_in, where compiler/emit/emit.ty (line 7937 at b9df4aa) stores it as 'cs.dbgfile'. compiler/emit/emit.ty@_block (line 4984) then builds '"#line " + str(line) + " \"" + cs.dbgfile + "\"\n"' without escaping anything. tychoc does the same job at src/tychoc.c (line 15455 at b9df4aa) with 'g_line_file = c_escape_path(input)', and 'c_escape_path' (src/tychoc.c (line 15244 at b9df4aa)) escapes '\' and '"'. tychoc1 has no equivalent, so cc reads the path's backslashes as C escape sequences (\t becomes TAB, \x and \u become hard errors) and a '"' ends ...
- Suggested fix: Add a c_escape_path equivalent in compiler/emit and apply it to cs.dbgfile before wrapping in quotes; escape at least backslash, double-quote and newline.
D13. Exponential compile time and memory on nested calls. tychoc1: method-call chains (generic or not), nested generic calls, nested generic struct constructors. tychoc: nested generic enum constructors
medium, verified; compilers: both. Current disposition: fixed in FRICTION #200.
- Where: tychoc1: compiler/emit/emit.ty _callval (3402-3428), which calls '_argtypes(&cs, r)[0]' (4003-4023, a full '_ex' emission of the receiver) only to learn its type, then '_call' emits the receiver again. Generic callees run '_argtypes' again (3736, 3763, 3981). tychoc: src/tychoc.c ...
- Observed: Output of repro.sh: tychoc t1_chain_short rc=0 .016627821s tychoc t1_chain rc=0 .024137546s tychoc t0_enum_short rc=0 .009434948s tychoc t0_enum rc=0 2.405531291s tychoc1 t1_chain_short rc=0 .010107069s tychoc1 t1_chain rc=0 4.073559487s tychoc1 t0_enum_short rc=0 .013507284s tychoc1 t0_enum rc=0 .010196836s Original repro, re-run: tychoc fx_expo_method 0.009s | tychoc1 24.753s rss=988MB tychoc ...
- Mechanism: There are three independent sources, and each re-processes a subtree at every nesting level, so each level multiplies the work. 1. tychoc1 emit pass, compiler/emit/emit.ty@_callval (line 3415): 'rt0 := _argtypes(&cs, r)[0]' fully emits the receiver through '_ex' and discards the code just to learn its type. '_call' then emits the receiver again, so a UFCS chain costs 2x per level. For generic callees, '_call' also calls '_argtypes' on the arguments: - generic function: line 3736 - generic struct: line 3763 - generic enum inference: line 3981 That is a third emission, which gives about 3x per level for a generic method chain, and 2x per level for nested generic calls or nested generic struct ...
- Suggested fix: tychoc1: get argument types from the types pass (or a memo keyed by node) instead of re-emitting. Compute '(code, type)' once per argument and reuse it in _call/_callval. tychoc: resolve each payload once (resolve_exp against the bound payload type after the instantiation, or skip args whose e->type is already set). Pin with a timed fixture, e.g. a 30-deep method chain under 1 s.
D14. An elif chain is lowered to one nested C block per arm: tychoc SIGSEGVs at ~7000 arms, clang (a supported cc) rejects chains of 255+ arms, and the emitted C grows quadratically (5000 arms -> 200 MB of C)
medium, verified; compilers: both.
- Where: tychoc: gen_stmt/gen_block recursion over the nested-else AST, with no depth guard (src/tychoc.c gen_stmt -> gen_block -> gen_stmt). tychoc1: emit.ty _stmt IfS arm (5951-5957). Both emit '} else {' + an indented 'if', one level per arm.
- Observed: clang (both compilers, 255 arms): .../elif255/a.c:...: fatal error: bracket nesting level exceeded maximum of 256 note: use -fbracket-depth=N to increase maximum nesting level tychoc: C compilation failed (clang -O3 ...) rc=1 The same file built with gcc prints 508; the 254-arm file under clang prints 506. tychoc, 7000 arms (as reported): Segmentation fault ./tychoc .../main.ty --emit-c -o ...; ...
- Mechanism: Both compilers represent 'elif' as a single nested 'if' inside the else-branch, then walk and emit that tree recursively. tychoc: - src/tychoc.c@parse_if (around line 3562) recurses once per elif ('s->els[0] = parse_if(...)'). - src/tychoc.c@resolve_stmt and src/tychoc.c@resolve_block recurse once per arm, with no depth guard; this is the crash at 100k arms. - src/tychoc.c@gen_stmt (12367) -> gen_block (13315) -> gen_stmt emits '} else {' plus one extra indentation step per arm, with no depth guard. This is the SIGSEGV at about 6300 arms on an 8 MB stack. - The result is C that is O(n^2) in size and n braces deep. clang's default -fbracket-depth=256 rejects it from 255 arms on. tychoc1: - ...
- Suggested fix: Emit an elif chain iteratively as 'if (..) {..} else if (..) {..}' at a single depth, in both compilers. This removes the recursion, the O(n^2) indentation and the bracket depth together. Pin with a 10000-arm fixture under gcc and a 300-arm fixture under clang.
D15. tychoc1 program_in copies the whole struct-definition text once per field: 900 structs x 20 fields (405 KB) need 4 GB, 2000 structs x 10 fields need 5.6 GB
medium, verified; compilers: tychoc1.
- Where: compiler/emit/emit.ty program_in, 8169-8173: 'head = head + " " + _ctyz(&cs, cs.sds[i].ftypes[j]) + "f_" + cs.sds[i].fnames[j] + ";\n"' inside the per-struct, per-field loop. Because 'head' is later reassigned by a prepend ('head = atd + asb + msb + osb + "\n" + head', 8266-8267), it is not treated ...
- Observed: Original repro: tycho: out of memory rc=1 wrote .../r900/o0.c (tychoc rc=0) Without ulimit: tychoc1 r900/main.ty src=405502B rc=0 wall=2.85s rss=4096MB Minimal (min/run.sh): tychoc n=200 src=26718B rc=0 maxrss=9MB tychoc1 n=200 src=26718B rc=0 maxrss=58MB tychoc n=400 src=53518B rc=0 maxrss=9MB tychoc1 n=400 src=53518B rc=0 maxrss=218MB tychoc n=800 src=107118B rc=0 maxrss=9MB tychoc1 n=800 ...
- Mechanism: compiler/emit/emit.ty@program_in, lines 8169-8192. The 'for i in _struct_order(&cs)' loop builds every struct definition with 'head = head + ...': once for the struct header, once per field (8172-8173) and once for the closer. 'head' is also reassigned by a prepend, 'head = atd + asb + msb + osb + "\n" + head' (8266). In that statement the leftmost term is 'atd', so _acc_scan (emit.ty (line 7560-7606 at b9df4aa)) records 'head' as an 'other' assignment, and _fnbody (7778-7788) drops it from cs.accs. Without accumulator status, every append in the loop becomes a full tycho_str_concatN copy of all struct text emitted so far, and each copy stays in program_in's arena. That makes memory and ...
- Suggested fix: Collect the struct definitions in their own string that is only ever appended to, or in a [string] joined once, and prepend it to head in a single step afterwards. Check the other 'xpro'/'xps' loops in program_in for the same shape.
D16. tychoc1 has neither of tychoc's expression-depth guards: a flat 1400-term sum aborts tychoc1 with an unlocated 'stack overflow' that tychoc compiles, and 256- to ~2000-deep nested calls are accepted against spec §3.9
medium, unverified; compilers: both.
- Where: compiler/types/tcheck.ty _ex2 <-> _binop recursion (no depth counter). compiler/parse/parse.ty has only the type-depth guard ('depth > 512', line 255) and no expression-nesting guard. tychoc has TYCHO_MAX_TREE_DEPTH=2000 (src/tychoc.c (line 6365 at b9df4aa), 6455) and TYCHO_MAX_PARSE_DEPTH=256 ...
- Observed: fx_sum1400 ('x := len(args())' then 'println(str(x + x + ... + x))', 1400 terms, 5.6 KB): built .../fx_sum1400/a0 2800 tycho: stack overflow -- recursion too deep tychoc1 rc=1 (tychoc1 accepts 1200 terms and fails at 1400. tychoc accepts up to 1999 terms and at 2001 prints a located 'main.ty:N: error: expression too deeply nested to type-check (max 2000)'. gdb: h_types___ex2 <-> ...
- Mechanism: tychoc1's typechecker recurses once per binary operator down the left spine with large frames (~3 KB per _ex2/_binop pair), so an 8 MB stack runs out near 1300 terms. Neither depth guard was ported from src/tychoc.c.
- Suggested fix: Port TYCHO_MAX_TREE_DEPTH into tcheck.ty _ex2, and into emit where it recurses the same way, with a 'file:line: error: expression too deeply nested to type-check (max 2000)'. Port the 256 parse-depth guard into parse.ty's unary/postfix path. Better still, make + chains iterative over the left spine so 2000 terms fit.
D17. Emitted C nests one bracket level per term of a flat '+' chain or f-string piece, so clang (a supported cc) rejects accepted programs: a 256-term sum, a 130-hole f-string
medium, unverified; compilers: both.
- Where: Binary-operator emission in both compilers (each op fully parenthesised: '((a + b) + c)'). f-strings: compiler/emit/emit.ty _fchain (2604-2612) wraps each piece past the 6th in another '({ char *_fiN = <inner>; tycho_str_concat(...); })', and tychoc does the equivalent. The driver passes no ...
- Observed: fx_clang_sum256 (1072 bytes, 'println(str(x + x + ... + x))' with 256 terms): .../fx_clang_sum256/a.c (line 3221 at b9df4aa):322: fatal error: bracket nesting level exceeded maximum of 256 1 error generated. tychoc: C compilation failed With gcc: built .../g, which prints 256. The boundary for both compilers is 250 terms OK, 256 fail. fx_clang_fstr130 (580 bytes, f"{x}-{x}-..." with 130 holes): ...
- Mechanism: The C nesting depth is proportional to a flat source construct: one paren level per left-associative operator, and one statement-expression level per f-string piece. clang's default -fbracket-depth is 256.
- Suggested fix: Emit left-associative chains without the redundant inner parentheses (or break long chains into temporaries), and emit f-strings as a flat sequence of appends. Cheapest option: add -fbracket-depth=<N> to the cc line when the compiler identifies as clang, in both drivers.
D18. A constant array literal is emitted as one store per element inside a statement expression, and gcc -O3 needs ~1.2 ms per element: a 15,625-element table (61 KB) builds in 21 s, 125,000 in 153 s, 250,000 in over 280 s
medium, unverified; compilers: both.
- Where: Array-literal codegen in both compilers: 'TychoArrInt h_xs = ({ TychoArrInt _l0 = tycho_arr_int_with_cap(&_scope, N); _l0.data[0] = 0LL; _l0.data[1] = 1LL; ... _l0.len = N; _l0; });'
- Observed: built .../fx_arr15625/a tychoc1 build real 21.017s 15625 Harness (front end, then cc -O3 on the emitted C): arraylit n=15625 tychoc 0.02s | cc -O3 18.69s arraylit n=31250 tychoc 0.05s | cc -O3 36.63s arraylit n=62500 tychoc 0.08s | cc -O3 74.12s arraylit n=125000 tychoc1 0.09s | cc -O3 153.52s arraylit n=250000 tychoc1 0.20s | cc -O3 TIMEOUT at 280.01s The same 62,500 values written as 'static ...
- Mechanism: N separate element stores inside one GNU statement expression give gcc's -O3 pipeline (store merging, SLP, DSE) one enormous basic block to optimise.
- Suggested fix: When every element is a compile-time scalar constant, emit a 'static const <elem> _litN[] = {...};' and 'memcpy' it into the arena allocation (strings: a static table of pointer/length pairs). Keep the per-element form for non-constant elements.
D19. tychoc1 takes quadratic memory on one long f-string (4000 holes on one line: 5 GB, 14 s) and on lambdas in a function with many locals (8000 lambdas: 5.7 GB)
medium, unverified; compilers: tychoc1.
- Where: compiler/emit/emit.ty _fchain (2604-2612): recursive, re-wrapping the inner string once per piece. _lambda (2861ff): 'sv := cs.vnames', 'svt := cs.vtypes', 'svc := cs.vcn', 'svcaps := cs.vcaps', 'svo := cs.vown' deep-copy every enclosing local's bookkeeping per lambda and assign it back afterwards.
- Observed: Under a 3 GB cap: tycho: out of memory rc=1 tycho: out of memory rc=1 wrote .../fx_lambda8000/o0.c rc=0 Uncapped: fholes n=1000 src=4060B tychoc1 1.15s rss=265MB fholes n=2000 src=8060B tychoc1 2.99s rss=1217MB fholes n=4000 src=16060B tychoc1 14.42s rss=5019MB closures n=2000 tychoc 0.06s 12MB | tychoc1 0.46s 377MB closures n=4000 tychoc 0.17s 22MB | tychoc1 1.82s 1540MB closures n=8000 tychoc ...
- Mechanism: _fchain re-concatenates the whole inner chain at every piece. _lambda snapshots and restores the full per-function variable tables by value per lambda. Both keep O(n) copies of O(n) data alive in the long-lived emitter state.
- Suggested fix: Make _fchain iterative (one flat concat sequence). In _lambda, save and restore by length marks (the _mark/_restore pattern _block already uses) instead of copying the arrays.
D20. tychoc1 accepts a tuple literal whose elements adapt to a typed tuple, e.g. 'p: (float, u8) = (1, 2)', and emits C that cc rejects.
medium, unverified; compilers: tychoc1.
- Where: compiler/emit/emit.ty (line 2393 at b9df4aa) 'ast.Tuple' emission. Each element is emitted under the wanted element type, but the tuple's C struct is built from the elements' synthesized types (int, int -> TychoTup1) and not from the wanted tuple type (TychoTup0), so it is assigned to a TychoTup0. ...
- Observed: tychoc : 1.0 2 tychoc1: .../F05_tuple_adapt/tychoc1.bin.c (line 3224 at b9df4aa):21: error: invalid initializer 3224 | TychoTup0 h_p = ({ TychoTup1 _tp0; _tp0.f0 = 1LL; _tp0.f1 = 2LL; _tp0; }); tychoc: C compilation failed (exit 1) The same class of cc error happens for a return '-> (f32, u8)' with '(1.5, 7)' (invalid initializer), a call argument (incompatible type for argument 2 of 'h_g'), ...
- Mechanism: The tuple literal's C type is derived from the element expressions' own types instead of the destination tuple type, and the element values are not coerced to it.
- Suggested fix: When 'cs.want' is a tuple type, emit the literal as the wanted TychoTupN and coerce each element to the wanted element type. Alternatively, compute ets from wts when the element is a literal. Pin with a fixture that builds '(float, u8)' and '(f32, i8)' tuples in decl, return, arg, push, Option and struct-field positions under both compilers.
D21. Both compilers pass a misaligned pointer when a field of a 'packed struct' is taken as an 'inout' argument, which is undefined behaviour in the emitted C.
medium, unverified; compilers: both.
- Where: The inout-argument lowering in both emitters (src/tychoc.c and compiler/emit/emit.ty) emits 'h_bump(&t, &(((h_r).f_count)))' for a member of an '__attribute__((packed))' struct and passes it as a 'long long *'. The same happens through an array element ('&rs[1].count') and through a subscript that ...
- Observed: tychoc : e_tychoc.c (line 3249 at b9df4aa):44: warning: taking address of packed member of 'struct S_Rec_' may result in an unaligned pointer value [-Waddress-of-packed-member] e_tychoc.c (line 3242 at b9df4aa):29: runtime error: load of misaligned address 0x7f2467600021 for type 'long long int', which requires 8 byte alignment tychoc1: e_tychoc1.c (line 3232 at b9df4aa):21: warning: taking ...
- Mechanism: The inout lowering always takes the field's address directly and never considers that the containing struct is packed.
- Suggested fix: When the inout place ends in a member of a packed struct, or any path through one, emit '{ T tmp = place; f(&tmp); place = tmp; }'. Pin with a fixture that runs under the sanitizer lane (inout on 'packed struct { u8; i64 }' .count, directly and via '[P]' and a subscript).
D22. tychoc1 makes 'to_u8'..'to_i64' of a float or f32 abort on NaN, infinity or any value outside the int64 range (including 'to_u64(1e19)', which fits a u64). The spec says these conversions are total, and tychoc gives the defined wrapped result.
medium, unverified; compilers: tychoc1.
- Where: compiler/emit/emit.ty (line 4282 at b9df4aa) lowers 'to_<sized>(float)' as '((ctype)tycho_f2i(c))'. tycho_f2i is the checked to_int path and exits on out-of-range input. tychoc uses runtime/tycho_rt.c (line 393 at b9df4aa) 'tycho_f2u64', which is total (non-finite -> 0, else truncate and wrap mod ...
- Observed: tychoc : 10000000000000000000 0 exit=0 tychoc1: tycho: float-to-int conversion out of range exit=1 The sweep gives the same split for every one of to_u8/u16/u32/u64/i8/i16/i32/i64 at 1e19, -1e19, 3.4e38, 3.5e38, 1e300, 2^63, NaN, +inf and -inf.
- Mechanism: The emitter reused the to_int helper for the sized family instead of the total helper the runtime provides.
- Suggested fix: emit.ty (line 4282 at b9df4aa): emit '((ctype)tycho_f2u64(c))' for every non-f32 sized target, as src/tychoc.c does. Pin with a fixture printing to_u64(1e19), to_u8(NaN) and to_i32(to_f32(3e10)) compiled by tychoc1 and diffed against tychoc.
D23. tychoc1's memory use grows super-linearly with the length of an if/elif chain: 500 arms take 1.3 GB, and 1000 arms (2004 lines) exceed 8 GB and die. tychoc compiles the same file in 0.1 s.
medium, unverified; compilers: tychoc1.
- Where: The tychoc1 passes after parsing (type check and/or emit) over 'ast.IfS(l, [c], body, els)', where each 'elif' is an IfS nested in the previous one's 'els' ([Stmt]). Parsing alone is fast: './tychoc1 F.ty --symbols' takes 0.09 s on the 750-arm file. The blowup is consistent with value-semantic ...
- Observed: tychoc : wrote .../F07_elif_chain/e_tychoc.c exit=0 secs=.1047 tychoc1: tycho: out of memory exit=1 secs=6.545 Peak RSS for tychoc1 (getrusage): 250 arms 179 MB, 500 arms 1345 MB, 750 arms 4438 MB, 1000 arms >7.8 GB (OOM). Time: 500 arms 1.07 s, 750 arms 3.1 s. With no ulimit, the 2000- and 4000-arm files were SIGKILLed (rc -9) after about 30 s, while tychoc took 0.13 s and 0.43 s. For ...
- Mechanism: Probably a per-level copy of the rest of the chain: the 'els' [Stmt] holding the next IfS is deep-copied (or re-walked) at each of the n levels of the recursive check or emit. Memory rises about 7.5x for each doubling of the chain. This is inferred from the measurements, not traced to a line.
- Suggested fix: Represent elif as a flat list of (cond, body) arms in IfS, or walk the else-chain iteratively and pass 'els' by inout rather than copying it at each level. Add a scaling gate: a 2000-arm elif chain must compile under tychoc1 within a fixed memory and time budget.
D24. An f-string hole parses its first expression and silently discards the rest, so 'f"{price:.2f}"', 'f"{n )))}"' and 'f"{n, 99}"' compile and print the bare value. A space after '{', which the spec itself writes as '{ expr }', is rejected as "unexpected indentation".
medium, unverified; compilers: both.
- Where: src/tychoc.c@desugar_interp (~2842-2890): 'Expr *ex = parse_expr(&sp);' with no check that sp reached end of input, and 'lex(sub)' lexes the hole as a whole file, so leading whitespace becomes an INDENT. compiler/parse/parse.ty (line 495 at b9df4aa) '_hole' does '_expr_into(ht, 0, &e)' and ignores ...
- Observed: Both compilers, F09_fstring_tail: price=3.14159 <- source: f"price={price:.2f}" n=7 <- source: f"n={n )))}" n=7 <- source: f"n={n, 99}" Both compilers, F09b_fstring_space (f"n={ n }"): main.ty (line 3 at b9df4aa): error: unexpected indentation: this line is indented further than the block it belongs to, and nothing above it opens a new block Parity: f"[{b undefined_name(}]" -> tychoc prints ...
- Mechanism: Hole sub-parsing reuses the whole-file lexer and the general expression parser without asserting that the parser consumed all of the hole's tokens.
- Suggested fix: After parsing the hole, require the next token to be EOF or NEWLINE and otherwise die with a hole-specific message. Strip leading and trailing whitespace from the hole text before lexing it, or lex it in bracket-depth mode so no INDENT is produced. Skip char literals in the brace scanner as string literals are already skipped. Pin with reject fixtures for '{x:.2f}' and '{x, y}', and a positive ...
D25. tychoc1 type-checks two documented constructs and then dies in its emitter with an unlocated internal error: the builtin 'pow(x, y)', and 'for x in s' over an 'soa'. tychoc compiles both.
medium, unverified; compilers: tychoc1.
- Where: compiler/types/tcheck.ty (line 889 at b9df4aa) registers '_bsig(&cs, "pow", "float", ["float", "float"])', but compiler/emit/emit.ty has no arm for it and reaches '_bad("unsupported call '" + n + "' ...")' at emit.ty (line 4399 at b9df4aa). The soa case is emit.ty (line 6095 at b9df4aa) ...
- Observed: F10_pow (println(str(pow(2.0, 10.0)))): tychoc : 1024.0 tychoc1: tychoc: unsupported call 'pow' with 2 argument(s) (compile exit 1; the method form x.pow(10.0) fails the same way) F10b_soa_forin (for p in s over 'soa []P'): tychoc : 3 tychoc1: tychoc: for-in over 'O~0' -- only arrays here, maps are Phase 8 (compile exit 1)
- Mechanism: The two tychoc1 phases disagree. The checker admits both constructs and the emitter has no lowering for either.
- Suggested fix: Add a 'pow' arm next to sqrt/floor/fabs in emit.ty's builtin call lowering. Add a soa case to the for-in lowering: gather element i from the column arrays, as the soa 's[i]' read already does. Pin with a tests/ fixture per construct compiled by tychoc1, and consider a gate asserting that no tcheck-registered builtin reaches '_bad("unsupported call")'.
D26. Compiler-generated temporaries share the user identifier namespace: a user variable named '_fc0', '_cx0' (tychoc) or '_mx36_0_0' (tychoc1) is silently replaced by the compiler's temp and the program prints a wrong answer
medium, unverified; compilers: both.
- Where: tychoc: parse-time desugar names in src/tychoc.c. The for-in collection and index use 'sfmt("_fc%d")'/'sfmt("_fi%d")' (:4347), hoist_place_leg uses '_cx%d' (:3937) and multi-assign uses '_ms%d' (:4477). tychoc1: compiler/parse/parse.ty '_mhoist' uses '"_mx" + str(i0) + "_" + str(tag) + "_" + ...
- Observed: f_fc0: '_fc0 := [100, 200, 300]' / 'xs := [1, 2, 3]' / 'if len(xs) > 0:' / ' for x in xs:' / ' s += x * _fc0[0]' -> tychoc '6', tychoc1 '600' f_cx0: '_cx0 := 1000' / 'if len(xs) > 0:' / ' xs[one()] += _cx0' -> tychoc '1', tychoc1 '1000' f_mx36: '_mx36_0_0 := 50' / 'if i > 0:' / ' (xs[i], b) = (_mx36_0_0, 5)' -> tychoc '50 5', tychoc1 '1 5' fi (same-scope case, tychoc): 'main.ty (line 6 at ...
- Mechanism: The desugaring introduces Tycho-level declarations with spellable names. In a nested block they shadow the user's outer binding, and in the same block they collide with it.
- Suggested fix: Name desugar temps with a character the lexer never produces in an IDENT (e.g. '$fc0', or a '__t_' form the lexer refuses), or have the emitters map them to a distinct C prefix that user names cannot reach. Add a fixture per temp kind.
D27. tychoc1 lowers 'to_u8'..'to_i64' of a float through the aborting 'tycho_f2i', so the spec-total conversions abort on NaN, inf and out-of-range input, including the in-range 'to_u64(1e19)'
medium, unverified; compilers: tychoc1.
- Where: compiler/emit/emit.ty ~4276-4283: 'return ("((" + _cty(&cs, w) + ")tycho_f2i(" + c + "))", w)'. src/tychoc.c (line 11405-11416 at b9df4aa) uses 'tycho_f2u64' (runtime/tycho_rt.c (line 393 at b9df4aa)), which tychoc1 never references.
- Observed: f_f2sized: 'fn big() -> float: return 1e19' / 'println(str(to_u64(big())))' tychoc: '10000000000000000000' rc=0 tychoc1: 'tycho: float-to-int conversion out of range' rc=1 The sweep over all 8 sized types with NaN, 1e300, 1e19 and -1e19 gives DIFF on every one, e.g. 'DIFF to_u8(0.0 / z): tychoc=[0] tychoc1=[tycho: float-to-int conversion out of range]' and 'DIFF to_u32(z + 1e19): ...
- Mechanism: tychoc1 reuses the 'to_int' guard for the sized conversions.
- Suggested fix: Emit '((T)tycho_f2u64(x))' for float/f32 into a sized int, as tychoc does. Add a parity fixture over NaN, ±inf, 1e19 and -1e19 for each width.
D28. Enum payload-variant names reach C unmangled, so a variant named 'EOF', 'NULL', 'BUFSIZ', 'INT64_MAX', 'linux', 'unix', 'default', 'case', 'while' or 'switch' fails the C build; 'enum Shape {Circle(..)}' next to 'enum Shape_Circle' collides on the typedef 'E_Shape_Circle'
medium, unverified; compilers: both.
- Where: Enum layout emission. tychoc src/tychoc.c (line 13980 at b9df4aa) ('union { E_<enum>_<variant> <variant>; } u;') and the uses at :11151, :12270, :13068, :14598-14647 ('u.%s' with var->name). The payload typedef 'E_%s_%s' joins enum and variant with a single '_'. tychoc1's enum emission has the same ...
- Observed: f_variant_eof: 'enum Tok:' / ' EOF(int)' / ' Word(string)' ... 't := EOF(42)' / 'match t:' / ' EOF(pos): ...' tychoc: 'f_variant_eof/tychoc.c (line 3217 at b9df4aa):43: error: expected identifier or '(' before '-' token'; tychoc1: 'f_variant_eof/tychoc1.c (line 3217 at b9df4aa):43: error: expected identifier or '(' before '-' token'. f_enum_typedef ('enum Shape: Circle(float), Empty' + 'enum ...
- Mechanism: The union member is the raw variant name, so libc and predefined macros and C keywords land in C syntax. The typedef name 'E_<enum>_<variant>' is not injective across enums because '_' is legal inside both halves.
- Suggested fix: Emit union members with a prefix ('v_<name>' or 'v<idx>') and payload typedefs with a separator a Tycho name cannot contain, or by index ('E_<enum>__P<idx>'). Add fixtures for 'EOF(int)' and for the Shape/Shape_Circle pair.
D29. Composite map keys with a float leaf break the '=='/hash contract: 'K(0.0) == K(-0.0)' is true but the hashes differ, so a map holds two '=='-equal keys
medium, unverified; compilers: both.
- Where: Float-leaf hashing. tychoc src/tychoc.c (line 10964-10965 at b9df4aa) and tychoc1 compiler/emit/emit.ty (line 641 at b9df4aa)/669 hash '((union { double _d; tycho_int _l; }){ ._d = x })._l', the raw bit pattern, while the generated equality uses C '=='.
- Observed: f_negzero_key: 'struct K: a: float'; z := negz() (returns -0.0) 'println(str(K(0.0) == K(z)) + " " + str(hash(K(0.0)) == hash(K(z))))' -> 'true false' (both compilers) 'm[K(0.0)] = 1; m[K(z)] = 2; println(str(len(m)) + ...)' -> '2 true' (both compilers) m07: after 'm[K(0.0)] = 1', 'K(z) in m' is 'false' and 'm[K(z)]' is '0'; the same happens with '(0.0, 1)' tuple keys and '[0.0]' array keys.
- Mechanism: The bit-pattern hash distinguishes +0.0 (0x0) from -0.0 (0x8000000000000000), but IEEE '==' does not.
- Suggested fix: Canonicalize before hashing a float or f32 leaf: 'double _h = (x == 0.0) ? 0.0 : x;'. Optionally canonicalize NaN too, although NaN != NaN stays unfindable by design. Fix both the seeded and the deterministic ('tycho_dhash') paths in both emitters.
D30. tychoc1 accepts a struct map key with a sized-int or f32 leaf (tychoc and the spec refuse it) and then emits a call to a hash function it never defines, so the link fails
medium, unverified; compilers: tychoc1.
- Where: compiler/types/tcheck.ty '_key_hashable' (:636-640) accepts 'k == "f32" or _is_int(k)', i.e. every sized int, as a leaf. src/tychoc.c@key_hashable and docs/spec/03-types.md (line 326 at b9df4aa) do not. The emitter then references 'tycho_hash_S_<K>' without emitting it.
- Observed: f_sized_key: 'struct Px: r: u8, g: u8' / 'm := []Px: int' / 'm[Px(to_u8(1), to_u8(2))] = 1' tychoc: 'main.ty (line 6 at b9df4aa): error: map keys must be string, int (directly or through a newtype), a fieldless enum, or a hashable struct/tuple/array' tychoc1: 'tychoc1.c (line 3294 at b9df4aa):97: warning: implicit declaration of function 'tycho_hash_S_Px'; did you mean 'tycho_str_S_Px'? ...
- Mechanism: tychoc1's leaf predicate is wider than tychoc's and the spec's, and its hash emitter has no arm for those extra leaves.
- Suggested fix: Make '_key_hashable' match src/tychoc.c@key_hashable (drop 'f32' and the sized ints), or implement sized-leaf hashing in both compilers and the spec together. Add a reject fixture.
D31. tychoc front-end (parse+resolve) is quadratic O(N^2) in top-level procedure count; ~15x slower than tychoc1 and widening
low, fixed; compilers: tychoc. Current disposition: FRICTION #198 and #201.
- Where: src/tychoc.c
sig_find/sig_find_quiet,consts_find,variant_find,generic_find, andwarn_atsource-snippet lookup. - Observed: Baseline minimized
--emit-c: N=5,000 procedures took tychoc 0.492s vs tychoc1 0.065s; N=20,000 took 9.188s vs 1.141s; N=40,000 took 47.176s vs 3.997s. Callgrind attributed 75.61% of N=5,000 instructions tosig_find. The signature index reduced the 40,000-procedure chain below the 20-second limit. The remaining registries and warning locations now pass a stress input with 12,000 constants and generics, 1,500 variants and 6,000 warnings. - Mechanism and fix: All four name registries now use append-aware, open-addressed indexes that preserve first-match semantics.
warn_atbuilds a line-start table once for the active source instead of rescanning the source for every warning.tests/proc_signature_scale.shandtests/name_registry_scale.shpin the large paths. - Suggested fix: Resolved; no remaining linear registry or warning-source scan from this finding is known.
D32. --symbols disagrees on a name-resolution error: tychoc exits 1, tychoc1 exits 0 and still prints the index
low, verified; compilers: both.
- Where: src/tychoc.c (line 15460-15464 at b9df4aa) (emit_symbols runs after resolve_program+g_ndiags check) vs compiler/driver/driver.ty (line 734 at b9df4aa) emit_symbols -> types.load_into (lex+parse only, no resolve)
- Observed: min/a (fn main(): println(zzz)): tychoc : .../min/a/x.ty (line 2 at b9df4aa): error: unknown variable 'zzz' tychoc=1 (stdout empty) tychoc1: fn main 1 fn main() tychoc1=0 (stderr empty) min/b (x := 1 + "s"): tychoc=1 "arithmetic requires two ints or two floats (got int, string)"; tychoc1=0 and prints "local x int 1 2" min/c (x := 1 unused): tychoc=1 "'x' declared and not used"; tychoc1=0 min/e ...
- Mechanism: compiler/driver/driver.ty@emit_symbols runs types.load_into (lex+parse), then types.check_syms(p), which is tcheck.ty@check_on(p, true). It never calls types.resolve(p), and it throws away the checker's errors: rs.errs and types.check_errs(cs) are never read. Compare driver.ty@check_into, which exits 1 on either. So every resolve, type or unused-local error is swallowed, and the index is printed with exit 0. src/tychoc.c@main reaches emit_symbols only after resolve_program, the 'if (g_ndiags) exit(1)' check and the 'g_unused_local' check (src/tychoc.c (line 15460-15464 at b9df4aa)), so it fails on any semantic error.
- Suggested fix: Decide one contract and make both match — e.g. have tychoc emit a best-effort index without failing on resolve errors, or have tychoc1 run resolve and exit non-zero on error before emitting.
D33. -o with an empty string: tychoc errors, tychoc1 silently ignores it and builds a default-named binary (fail-open)
low, verified; compilers: both.
- Where: compiler/main.ty (line 228-231 at b9df4aa) ('base := out; if len(base)==0: base = _strip_ext(src)') vs src/tychoc.c (line 15386 at b9df4aa) ('base = out ? xstrndup(out,...) : strip_ext(input)')
- Observed: tychoc1 build -o '': rc=0 binary=(scratch) (default name) /* Tycho runtime <- tychoc1 --emit-c -o '': C went to stdout tychoc build -o '': rc=1 stray=.c wrote .c tychoc --emit-c -o '': rc=0 stray=.c (The original repro_cmd gave: "cc: fatal error: output filename may not be empty / tychoc: C compilation failed (cc ... -o '' '.c' -lm ) / tychoc=1" and "built .../x / tychoc1=0".)
- Mechanism: compiler/main.ty@main: 'out := ""' means "no -o". The '-o' branch (line 117-121) stores 'av[i]' without checking it. 'if len(base) == 0: base = _strip_ext(src)' (228-230) and 'if len(out) == 0: ... to stdout' (233-235) then treat an explicit empty value as absent, so tychoc1 silently falls back to the default binary name or to stdout. src/tychoc.c@main (15344, 15386-15388): '-o' is also taken without checking ('out = argv[++i]'). 'base = xstrndup(out, 0)' gives 'c_path = ".c"', a relative path in the current directory. 'c_to_stdout = emit_c_only && !out' is false because 'out' is non-NULL. The build path writes './.c', runs 'cc -o ''', cc fails and the '.c' is kept. The --emit-c path writes ...
- Suggested fix: Track whether -o was supplied separately from its value; reject an explicitly-empty -o (matching tychoc's effective behaviour).
D34. Trailing bare -L/-I (no value): tychoc fails, tychoc1 swallows -lm and builds (fail-open)
low, verified; compilers: both.
- Where: compiler/main.ty (line 167-171 at b9df4aa) and compiler/driver/driver.ty (line 344-346 at b9df4aa) (xtra placed before -lm) vs src/tychoc.c (line 15363-15366 at b9df4aa) (extra placed after -lm)
- Observed: Original repro, baseline: cc: error: missing argument to '-L' tychoc: C compilation failed (cc -O3 -fwrapv -ffp-contract=off -pthread -I'corelib' -o '.../r/o' '.../r/o.c' -lm '-L' ) tychoc=1 built .../r/o tychoc1=0 (-I behaves the same: tychoc "cc: error: missing path after '-I'" rc=1; tychoc1 "built" rc=0) Minimized matrix (sqrt on a runtime value): tychoc [] rc=0 :: built tychoc [-L] rc=1 :: ...
- Mechanism: The cause is on the tychoc1 side. compiler/main.ty@main (lines 167-171, the '-L'/'-I' branch) pushes a bare '-L'/'-I' into xtra and takes the next argv entry as its value only when 'i + 1 < len(av)'. When the flag is the last argument it is kept with no value and no error, unlike '-o', '--cc', '--link', '--shim', '--pkg' and '--runtime', which call usage(). compiler/driver/driver.ty@build (lines 344-346) then emits 'for x9 in xtra: push(argv, x9)' BEFORE 'push(argv, "-lm")', so cc parses '-L -lm' as a search directory named "-lm" and libm is never linked. That is fail-open. tychoc has the same missing check: src/tychoc.c@main lines 15363-15366, 'if (!argv[i][2] && i + 1 < argc) ...', with ...
- Suggested fix: Validate that a bare -L/-I is followed by a value (error if it is the last argument), matching how -o/--cc/--target already require a value.
D35. tychoc never resets g_resolve_depth after recovering from an error, so after enough ordinary errors every later expression, valid code included, is reported as 'expression too deeply nested to type-check (max 2000)'
low, unverified; compilers: tychoc.
- Where: src/tychoc.c resolve_expr (6454-6460): '++g_resolve_depth' before die_at, 'g_resolve_depth--' only on normal return. The recovery points (setjmp at 9546 per statement, 10052 per proc) reset g_nvars, g_ncur_typarams, g_inst_from, g_dup_base and g_npoison, but not g_resolve_depth (nor g_clone_depth, ...
- Observed: fx_depth_leak2 (10 lines of println(str(f(f(...f(nope)...)))) with 200 nested calls, then 'y := 1 + 2' and 'println(str(y))'): main.ty (line 5 at b9df4aa): error: unknown variable 'nope' ... (lines 6-12 the same) main.ty (line 13 at b9df4aa): error: unknown variable 'nope' main.ty (line 14 at b9df4aa): error: expression too deeply nested to type-check (max 2000) main.ty (line 15 at b9df4aa): ...
- Mechanism: die_at longjmps out of resolve_expr frames that have already incremented g_resolve_depth. Each recovered error at nesting depth d leaks d, and the counter only grows until it crosses TYCHO_MAX_TREE_DEPTH.
- Suggested fix: Save g_resolve_depth (and g_clone_depth, g_affine_line) before each setjmp(g_recov) and restore them in the recovery branch at 9546 and 10052. Pin with fx_depth_leak2 as a tests/diag golden.
D36. tychoc1 mishandles user type names that contain '__': a main-package enum's variants are refused in 'match', and a struct named like a generic instance ('Box__int') breaks the emitter
low, unverified; compilers: tychoc1.
- Where: compiler/types/tcheck.ty '_foreign_variant' (:715-738) treats the first '__' in any enum name as a package prefix. src/tychoc.c (line 1374-1386 at b9df4aa) die_not_variant fires only when the variant is not found. tychoc1's generic-instance naming ('Box__int') shares the type table with a user ...
- Observed: f_enum_dunder ('enum Color__v2: Red, Blue' / 'c := Blue' / 'match c:' / 'Red: ...' / 'Blue: ...'): tychoc builds and prints 'blue'; tychoc1: 'main.ty (line 8 at b9df4aa): error: 'Red' is a variant of Color.v2, but a variant of another package's enum must be written qualified -- write 'Color.Red'' f_generic_collide ('struct Box($T): v: $T' + 'struct Box__int: label: string'; 'b := Box(5)'): tychoc ...
- Mechanism: tychoc1 infers "imported" from the spelling '__' rather than from the package table, and it names generic instances in the same flat namespace as user types without de-duplication.
- Suggested fix: In '_foreign_variant', require 'pkg' to be an imported package (as FRICTION #41's helper does) before refusing. Give generic instances a spelling no user type can take (e.g. 'Box$int'), or de-duplicate as tychoc does.
D37. A u64 shift count of 2^63 or more aborts with 'tycho: negative shift count', although the spec defines a count ≥ width as 0 and a u64 cannot be negative
low, unverified; compilers: both.
- Where: runtime/tycho_rt.c tycho_shl_i/tycho_shr_i/tycho_shl_u32/.../tycho_shln/tycho_shrn take the count as 'long long n' and test 'n < 0'. Both emitters pass a u64 count unconverted, so values ≥ 2^63 turn negative.
- Observed: f_u64shift: 'n := cnt()' (u64 18446744073709551615) / 'println("count " + str(n))' / 'x := 1' / 'println(str(x << n))' Both compilers: 'tycho: negative shift count' / 'count 18446744073709551615' / 'rc=1'
- Mechanism: The unsigned count is reinterpreted as signed 'long long' before the range check.
- Suggested fix: When the count's type is u64, emit '(n > 63 ? 64 : (long long)n)', or pass a separate is-unsigned flag. Alternatively, clamp in the emitter: '((uint64_t)n >= W ? W : n)'.
D38. tychoc prints a mangled package function name in the parameter-pattern mismatch error ('argument 2 of math__clamp'), a site the FRICTION #41 fix missed; tychoc1 says 'clamp'
low, fixed 2026-09-27.
- Observed:
math.clamp(v, [-3.0, 0.0, 0.0, 0.0], [255.0, 255.0, 255.0, 255.0])withv: vector[4]f32. - Mechanism and fix: the native
which does not fit the parameter patterndiagnostic formattede->svaldirectly. It now callsnominal_name(gt->name), producing the source-qualifiedmath.clampspelling instead of the internalmath__clampsymbol. - Regression:
tests/package_diagnostic_name.shchecks a non-mangled source spelling for both compilers.
D39. reserve(xs, n) on a composite-element array with attacker-controlled n has no size_t-overflow guard: (size_t)n*sizeof(elem) wraps to a tiny allocation under a huge cap -> heap-buffer-overflow / heap corruption
high, verified; compilers: both.
- Where: Generated composite-array reserve. src/tychoc.c (line 14305 at b9df4aa) (fprintf template 'tycho_arr_C%d_reserve') and compiler/emit/emit.ty (line 6784-6788 at b9df4aa) both emit 'arena_alloc(a, (size_t)n * sizeof(elem))' with NO tycho_cap_check, unlike the guarded scalar families ...
- Observed: min, plain build: tychoc prints '2' and exits 0; tychoc1 prints '2' and exits 0 (the neighbour ys[0].x should be 7, and the reserve should have aborted). Original repro, plain build: 'Fatal glibc error: malloc.c (line 2599 at b9df4aa) (sysmalloc): assertion failed: ...' Aborted, rc=134, on both compilers. ASan: '==23678==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x531000024818 ... ...
- Mechanism: tychoc: in src/tychoc.c, the fprintf template for 'static void tycho_arr_C%d_reserve' (~line 14305, the per-type composite array emitter). tychoc1: in compiler/emit/emit.ty, the '_need(&cs, p + "_reserve")' block (~lines 6782-6788). Both emit 'arena_alloc(a, (size_t)n * sizeof(elem))' with no tycho_cap_check(n, sizeof(elem)). For n > SIZE_MAX/sizeof(elem) the product wraps mod 2^64; for P, 16*(2^60+1) = 16 bytes. arena_alloc returns a tiny slice of an arena block while xs->cap is set to the huge n. push never reaches len==cap, so it never grows and writes linearly past the buffer, first into neighbouring arena allocations and then off the block. The guard exists only in runtime/tycho_rt.c ...
- Suggested fix: In both codegen sites (src/tychoc.c:~14305 and compiler/emit/emit.ty (line 6784 at b9df4aa)), emit 'tycho_cap_check(n, sizeof(<elem>));' immediately after the 'if (n <= xs->cap) return;' line, matching tycho_arr_int_reserve. Also audit the composite grow/with_cap paths for the same multiply. Pin with a composite-array reserve fixture in tests/abort/.
D40. Shipping self-hosted compiler tychoc1 cannot compile the documented builtin pow(x, y); tychoc accepts it (parity break + spec violation)
medium, verified; compilers: tychoc1 rejects, tychoc accepts.
- Where: compiler/emit/emit.ty (call-lowering in the E_CALL builtin dispatch around lines 4299-4399): sqrt/floor/fabs have emit cases but pow has none, so codegen falls through to _bad("unsupported call..."); pow IS registered in compiler/types/tcheck.ty (line 889 at b9df4aa) (_bsig "pow" float ...
- Observed: tychoc: "built .../min/b0", then b0 prints "8.0" (rc=0). tychoc1: "tychoc: unsupported call 'pow' with 2 argument(s)" (rc=1). No binary is produced; a truncated b1.c (runtime prelude only) is left behind. Control under tychoc1: println(str(sqrt(4.0)) + " " + str(floor(2.5)) + " " + str(fabs(-1.5))) prints "2.0 2.0 1.5".
- Mechanism: compiler/emit/emit.ty@_call builtin dispatch (lines ~4296-4315): it has cases for 'to_float', 'sqrt', 'floor' and 'fabs', but none for 'pow'. A pow call falls through to line 4399, '_bad("unsupported call '" + n + "' with " + str(len(args)) + " argument(s)")'. The front end accepts the call: compiler/types/tcheck.ty (line 889 at b9df4aa) '_bsig(&cs, "pow", "float", ["float", "float"])' and resolve.ty (line 90 at b9df4aa)/116/131. The bootstrap compiler handles it at src/tychoc.c (line 11442 at b9df4aa): 'if (!strcmp(e->sval, "pow")) return sfmt("pow(%s, %s)", ...)'. pow is also missing from emit.ty@_shadowable (line 4095), which lists sqrt and fabs, so the fix should add it there too for ...
- Suggested fix: Add a case to compiler/emit/emit.ty mirroring the sqrt case: 'if n == "pow" and len(args) == 2:' -> emit 'pow(<a>, <b>)' yielding float (and ensure both args coerce to double). Pin with a fixture compiled by tychoc1 that prints pow(2.0,3.0)==8.0, plus a parity check.
D41. channel(T, cap) with a large capacity overflows the cell-array size computation in tycho_chan_new -> tiny allocation, then the init loop writes out of bounds (heap-buffer-overflow / SIGSEGV)
medium, verified; compilers: both.
- Where: runtime/tycho_rt.c (line 1078-1096 at b9df4aa) tycho_chan_new: 'ch->cells = ty_aligned_alloc64((size_t)c2 * sizeof(HCell));' then 'for (i=0;i<c2;i++){ atomic_store(&ch->cells[i].seq,...); ... ch->cells[i].arena = arena_new(0); }'. Only 'cap < 1' is guarded (:1079); the size multiply is not.
- Observed: tychoc literal 2^56+1: 'Segmentation fault' rc=139 tychoc1 literal 2^56+1: 'Segmentation fault' rc=139 cap=2^56 (both compilers): 'tycho: out of memory' rc=1 cap=2^56+1 (both compilers): 'Segmentation fault' rc=139 cap=2^62+1 (both compilers): hang, killed by timeout, rc=124 sizeof(HCell)=128 sizeof(Arena)=48 prod=0 ((size_t)2^57 * sizeof(HCell)) ASan: '==ERROR: AddressSanitizer: ...
- Mechanism: runtime/tycho_rt.c@tycho_chan_new (lines 1085-1096). There are two unchecked steps. 1. Line 1089 computes 'ty_aligned_alloc64((size_t)c2 * sizeof(HCell))' without checking the product for overflow. sizeof(HCell) is 128, so any c2 >= 2^57 (any cap > 2^56) wraps the product to 0. aligned_alloc(64, 0) returns a non-NULL minimal chunk, so the 'if (!ch->cells) tycho_oom()' check passes, and the loop 'for (i = 0; i < c2; i++)' at lines 1091-1096 writes past it: seq at line 1092, then pos, val and arena_new(0). 2. Line 1086, 'while (c2 < cap) c2 <<= 1;', overflows a signed int64 for cap > 2^62: c2 becomes INT64_MIN, then 0, and the loop never ends. Fix: reject cap > ((size_t)-1 / sizeof(HCell)) / ...
- Suggested fix: In tycho_chan_new, after computing c2, guard the allocation size: e.g. 'if (c2 > (tycho_int)((size_t)-1 / sizeof(HCell))) { fprintf(stderr, "tycho: channel capacity too large\n"); exit(1); }' (and guard the c2 doubling loop against overflow for cap near INT64_MAX). Pin with an abort fixture.
D42. core:regex — a pattern UNDER the repetition-product ceiling still causes a 40+ second match-time DoS; the ceiling bounds compile memory, not match time
high, verified; compilers: both.
- Where: corelib/regex/regex_shim.c (RX_MAX_REPEAT_PRODUCT / rx_repeat_too_big + rx_exec/regexec); the whole point of the ceiling is stated in docs/internals/audit-2026-08-16.md §core:regex and ffi-review-2026-08-14.md finding 6
- Observed: Original repro: tychoc "match=false time=60804ms (14-byte pattern, 50000-byte subject)"; tychoc1 "match=false time=58957ms" (user CPU 52.5s / 56.5s). Minimized, tychoc: a+b n=6250 match=false 62ms a+b n=12500 match=false 241ms a+b n=25000 match=false 1084ms (a{316}){316} n=6250 match=false 287ms (a{316}){316} n=12500 match=false 2149ms (a{316}){316} n=25000 match=false 15445ms Minimized, tychoc1: ...
- Mechanism: The shim, corelib/regex/regex_shim.c@rx_exec (reached through rx_is_match, rx_find and rx_group), passes the whole subject to glibc regexec with no bound on subject length or match work. In glibc, posix/regexec.c@re_search_internal handles an unanchored pattern by trying every start offset that the fastmap admits. At each offset, check_matching runs the automaton forward until its state set dies or the subject ends. A failing search therefore costs the sum, over all start offsets, of how far each attempt runs: - O(n²) when the pattern's prefix accepts a long run of the subject: a+b, [a-z]+@, .+x. - O(n·L) when the prefix is a bounded repetition of length L, which can be up to 100000. The ...
- Suggested fix: Bound match cost too, not just compile memory: e.g. lower RX_MAX_REPEAT_PRODUCT substantially (the audit's own 'safe' example (a{100}){100}=10000 matches a 200KB subject in ~22ms), or cap subject length relative to state count, or document a hard match-time/state ceiling. A pinning gate should time is_match of a near-ceiling pattern against a fixed-size subject and fail if it exceeds a budget, ...
- *secondary defects — closed after the baseline:*
rx_execimposes a hard 4096-byte subject ceiling. The runtime-guard fixture now searches a 4096-byte failinga+bsubject (the quadratic-prefix shape from the repro) before asserting that the over-limit call is refused;tests/audit_runtime_guards.shapplies a 10-second portable timeout under both compilers. Manual boundary probing also measured the near-ceiling(a{316}){316}pattern at about 0.1 seconds on this host. This is a fixed input-work bound, not a claim of linear POSIX-regex complexity.
D43. core:crypto — the interior-NUL sweep (FRICTION #75–#78, declared COMPLETE) missed the entire crypto hex surface; sha256/sha512/hmac/pbkdf2-salt/aead/ed25519-sign/ed25519-verify/x25519/key_from_hex all truncate a hex argument at the first NUL, giving collisions
high, verified; compilers: both.
- Where: corelib/crypto/crypto_shim.c (hexdec/hexdec_ct use strlen; reached by cx_sha256_hex, cx_sha512_hex, cx_hmac_sha256_hex, cx_pbkdf2_sha256 salt, cx_aead_encrypt/decrypt, cx_ed25519_sign_hex, cx_ed25519_verify, cx_x25519_shared, cx_key_from_hex, cx_ed25519_key_from_seed, cx_x25519_key_from_secret)
- Observed: min (tychoc and tychoc1 identical): false true bceef655b5a034911f1c3718ce056531b45ef03b4c7b1f15629e867294011a7d bceef655b5a034911f1c3718ce056531b45ef03b4c7b1f15629e867294011a7d control (no NUL): sha256("aazz") -> !err, sha256("aab") -> !err poc (tychoc and tychoc1 identical): legit: ACCEPTED payload=7061793a3130 control: REJECTED forged: ACCEPTED payload=7061793a313030303030 original repro ...
- Mechanism: The cause is corelib/crypto/crypto_shim.c@hexdec_ct: 'size_t L = strlen(s);' (line 48). It is reached through hexdec(), which is called by: - cx_key_from_hex (and therefore ed25519_key_from_seed and x25519_key_from_secret) - digest_hex (sha256, sha512) - cx_hmac_sha256_hex - the cx_pbkdf2_sha256 salt - cx_aead_encrypt and cx_aead_decrypt (nonce, pt/ct, aad) - cx_ed25519_sign_hex - cx_ed25519_verify (pub, msg, sig) - cx_x25519_shared The Tycho wrappers in corelib/crypto/crypto.ty pass no length for any of these. Only ct_equal (a length checked against strlen) and the pbkdf2 password (an explicit pwlen) carry one. An interior NUL therefore ends the hex string early, and the prefix is decoded ...
- Suggested fix: Cross each hex argument as (pointer,len) like the other guarded packages, or add an explicit length parameter and refuse when it disagrees with strlen (exactly what cx_ct_equal now does). Extend corelib/test/crypto/main.ty to assert NUL-collision-free behaviour for sha256/hmac/verify/key_from_hex with controls beside them, matching the existing pw_nul / cte_nul legs.
D44. core:crypto hex inputs stop at the first NUL, so verify, HMAC and AEAD-decrypt accept input that was never signed or encrypted
high, verified; compilers: both.
- Where: corelib/crypto/crypto_shim.c@hexdec_ct (size_t L = strlen(s)); reached from every hex-taking wrapper in corelib/crypto/crypto.ty: sha256, sha512, hmac_sha256, aead_encrypt, aead_decrypt, ed25519_sign, ed25519_verify, key_from_hex, ed25519_key_from_seed, x25519_key_from_secret, x25519_shared, and ...
- Observed: Original repro (tychoc; tychoc1 IDENTICAL via cmp): sha256("61zz") = !err sha256("61") = ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb sha256("61"+NUL+"zz") = ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb control verify("6869") = true control verify("6870") = false control verify("6869zz") = false verify("6869"+NUL+"21212121") = true len(msg_hex)=13 ...
- Mechanism: corelib/crypto/crypto_shim.c@hexdec_ct line 48 'size_t L = strlen(s);' takes the hex length from the first NUL, so bytes after an interior NUL are never examined, and the even-length and hex-digit checks pass on the prefix. hexdec() wraps it, and every hex-taking cx_* extern calls hexdec. corelib/crypto/crypto.ty passes each hex string straight through with no len() and no has_nul guard. Only ct_equal (cx_ct_equal compares alen/blen against strlen) and the pbkdf2 password (pwlen) are protected. Fix pattern: pass len(x) with each hex argument and refuse when it differs from strlen, or refuse any NUL in the Tycho wrappers.
- Suggested fix: Pass len(x) next to every hex argument (the ct_equal pattern), and in hexdec_ct refuse when the passed length differs from strlen, or refuse any NUL in the Tycho wrappers. Pin it with verify/hmac/aead_decrypt/sha256 NUL cases next to the working controls in corelib/test/crypto.
D45. core:io — io.copy(src,dst) silently truncates dst to 0 bytes and returns Ok when src and dst are the same file (including via a symlink whose path differs)
medium, verified; compilers: both.
- Where: corelib/io/io_shim.c@iox_copy_file (fopen(src,"rb") then fopen(dst,"wb") with no same-file check) and corelib/io/io.ty@copy
- Observed: Original repro (tychoc): before: data.txt = 37 bytes io.copy(alias, data.txt) returned Ok after: data.txt = 0 bytes Minimal (tychoc1 and tychoc, identical output): same-path Ok, after=0 dot-path Ok, after=0 control Ok, after=3 Host tools on the same case: cp: '.../c.txt' and '.../c.txt' are the same file (exit 1) shutil.SameFileError: '.../c.txt' and '.../c.txt' are the same file tychoc1 driver: ...
- Mechanism: corelib/io/io_shim.c@iox_copy_file (lines 440-463): it opens src with fopen(src,"rb") and fstats it only to reject a directory. It then calls fopen(dst,"wb"), which truncates the inode that src also refers to. The fread loop reads 0 bytes, ferror(in) is false, and it returns TY_RF_OK. Nothing compares src and dst by (st_dev, st_ino) before the truncating open. corelib/io/io.ty@copy passes the status straight through as Ok. The same path reaches the compiler through compiler/driver/driver.ty@write_runtime (io.copy(runtime_candidate, out_c)), so tychoc1 can destroy its --runtime source when -o aliases it. Suggested fix: stat(dst) before fopen(dst,"wb"). If dst exists and has the same ...
- Suggested fix: After opening src, fstat it; before opening dst for write, stat/fstat dst and if it is the same (st_dev,st_ino) return Ok as a no-op (or a distinct Err), matching cp(1)/shutil. On Windows compare by handle identity (GetFileInformationByHandle nFileIndex/volume).
D46. core:sqlite open() and exec() act on the NUL-truncated prefix: open creates or opens a different database file, and exec runs a shorter statement
medium, verified; compilers: both.
- Where: corelib/sqlite/sqlite.ty@open (sqlite3_open(path, ...) with no NUL guard) and @exec (sqlite3_exec(d.h, sql, ...) with no NUL guard and no length)
- Observed: Both tychoc and tychoc1 print the same output: open(NUL path) = Ok seeded 3 exec(NUL sql) = Ok(3) Afterwards, work/ contains a.db. The path given was work/a.db + NUL + "/../b.db". Sibling probe (q/main.ty, tychoc): seeded 3 query(NUL sql) = Ok count=3 exec_params(NUL sql, []) = Ok(3) The original repro_cmd, re-run verbatim: io.exists(p) = false sqlite.open(p) = Ok exec ok exists tenant_a.db: true ...
- Mechanism: Three places in corelib/sqlite/sqlite.ty hand a length-carrying Tycho string to a C function that stops reading at the first NUL, and none of them checks for an interior NUL first: 1. @open (line 56) calls sqlite3_open(path, &db.h). It takes a plain char* path, which is NUL-terminated, so it opens or creates the file named by the prefix before the NUL. 2. @exec (line 76) calls sqlite3_exec(d.h, sql, ...). It takes a plain char* sql, so it runs only the prefix and returns Ok with the change count. 3. @_run_stmt (line 85) calls sqlite3_prepare_v2(d.h, sql, len(sql), ...). SQLite stops at the first NUL even when nByte is larger, so passing len(sql) does not help, whatever FRICTION #70 says. ...
- Suggested fix: Add the _has_nul guard to sqlite.open (return Err) and sqlite.exec (return Err). Optionally route exec through prepare_v2 plus a tail loop. Add fixtures with a NUL path and NUL SQL.
D47. core:http post/post_bytes pass a CR/LF in content_type into the outgoing request headers
medium, verified; compilers: both.
- Where: corelib/http/http.ty@post and corelib/http/http.ty@post_bytes (the guard covers NUL only) and corelib/http/http_shim.c@perform: snprintf(h, 512, "Content-Type: %s", ctype); curl_slist_append
- Observed: tychoc and tychoc1, identical output: post=200 post_bytes=200 POST / HTTP/1.1^M$ Content-Type: text/plain^M$ X-Injected: post^M$ POST / HTTP/1.1^M$ Content-Type: text/plain^M$ X-Injected: post_bytes^M$ Escalation (extra/, content_type = "text/plain\r\nContent-Length: 0\r\n\r\nGET /smuggled HTTP/1.1\r\nHost: victim\r\nX-Pad: "), one TCP connection: POST / HTTP/1.1^M$ Host: 127.0.0.1:28948^M$ ...
- Mechanism: corelib/http/http.ty@post and @post_bytes validate content_type with '_has_nul' only, so CR (0x0d), LF (0x0a) and other control bytes pass. corelib/http/http_shim.c@perform then runs 'snprintf(h, sizeof h /*512*/, "Content-Type: %s", ctype); curl_slist_append(hdrs, h); CURLOPT_HTTPHEADER'. libcurl writes CURLOPT_HTTPHEADER entries verbatim and does not validate them, so an embedded CRLF ends the Content-Type line and starts new header lines. A CRLFCRLF ends the request head and whatever follows is parsed as a second request. The snprintf also drops any value over 497 bytes without reporting it. Fix direction: refuse, returning the null handle, any byte < 0x20 other than TAB, or 0x7f, in ...
- Suggested fix: In post/post_bytes, refuse any byte < 0x20 (except TAB) or 0x7f in content_type. In the shim, refuse rather than truncate when snprintf reports truncation.
D48. regex.group_start/group_end/group: a huge group index wraps the allocation size and rx_exec writes past a 0-byte heap block
medium, verified; compilers: both.
- Where: corelib/regex/regex_shim.c@rx_group: nm = (size_t)n + 1; malloc(nm * sizeof(regmatch_t)) wraps to 0 for n = 2^61-1, then rx_exec writes m[0].rm_so/rm_eo (REG_STARTEND). The Tycho wrappers in corelib/regex/regex.ty do not bound n against ngroups.
- Observed: Reported repro, re-run exactly (tychoc, ASan): ==9441==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x5020000003f0 ... WRITE of size 4 at 0x5020000003f0 thread T0 #0 in rx_exec corelib/regex/regex_shim.c (line 36 at b9df4aa) #1 in rx_group corelib/regex/regex_shim.c (line 153 at b9df4aa) #2 in rx_group_start corelib/regex/regex_shim.c (line 159 at b9df4aa) 0x5020000003f1 is located 0 ...
- Mechanism: corelib/regex/regex_shim.c@rx_group: 'size_t nm = (size_t)n + 1; regmatch_t *m = malloc(nm * sizeof(regmatch_t));' does not check the multiplication and never compares n with ((regex_t*)re)->re_nsub. - On x86_64 glibc, regmatch_t is 8 bytes, so any n with n+1 = k*2^61 (for example n = 2^61-1) wraps the size to 0. A value such as n = 2^61 gives malloc(8). - rx_exec (REG_STARTEND branch) then writes m[0].rm_so and m[0].rm_eo, which is the ASan hit. - glibc regexec then writes pmatch[0..re_nsub] before refusing the huge nmatch, so a pattern with many groups overruns the chunk by (re_nsub+1)*8 bytes and corrupts the heap in plain builds. - The Tycho wrappers in corelib/regex/regex.ty ...
- Suggested fix: In rx_group, return -1 when n > re_nsub, and allocate re_nsub+1 entries (bounded) instead of n+1.
D49. io.read_line/fold_lines and os.run/exec_out silently cut data at the first NUL, while io.read/read_lines keep it
medium, verified; compilers: both.
- Where: corelib/io/io_shim.c@iox_read_line and corelib/os/os_shim.c@osx_run_out, both returning char* (strlen-bounded copy in runtime/tycho_rt.c@tycho_str_from_c); wrappers corelib/io/io.ty@read_line and corelib/io/io.ty@fold_lines and corelib/os/os.ty@run and corelib/os/os.ty@exec_out
- Observed: Original repro (tychoc): read_lines len=22 [user=admin^@,role=guest] read_line len=10 [user=admin] fold_lines len=10 [user=admin] exec_out(find -print0): code=0 len(out)=107 out=[.../os_out_nul/tree/one] (tree holds one, two, three) exec_out(find -print): code=0 len(out)=326 run(printf ok\0tail): code=0 len(out)=2 out=[ok] Minimized, identical on tychoc (m0) and tychoc1 (m1): read_lines=3 ...
- Mechanism: corelib/io/io_shim.c@iox_read_line: getline returns the line length n, but after stripping \n and \r the shim returns only the char* buffer. The extern is '-> Option(string)', so the value is ingested through the strlen-bounded runtime/tycho_rt.c@tycho_str_from_c and the length is lost at the first NUL. The Windows ty_getline stand-in behaves the same way. corelib/os/os_shim.c@osx_run (popen loop) and @osx_spawn (POSIX pipe loop, used by osx_exec_out) both count 'len' locally and then drop it. OsRun is '{code, char *out}' with no length field, and osx_run_out returns const char* through 'extern fn osx_run_out(r: ptr) -> string', which is strlen-bounded again. The Tycho wrappers ...
- Suggested fix: Return bytes through the (out, outlen) convention (or add a length out-param) and build the string from it. At minimum, document the truncation in io.ty/os.ty and docs/reference/corelib.md.
D50. core:compress — a valid gzip/deflate stream well under the documented 1 GiB ceiling is refused as TooBig; the doubling loop's real limit is ~512 MiB and it mislabels legitimate data a 'decompression bomb'
low, verified; compilers: both.
- Where: corelib/compress/compress_shim.c (zx_decompress and zx_raw_inflate: 'size_t ncap = cap*2u; if (ncap > ZD_MAX_OUT) { *status=ZD_TOOBIG; ... }')
- Observed: Full size (default 1 GiB ceiling), tychoc and tychoc1 identical: "TooBig (refused as a decompression bomb)" for p600.gz (600 MiB output) "Ok 524288000 bytes" for p500.gz raw 600MiB: dflt=TooBig ceiling2GiB=Ok 629145600 600MiB: dflt=TooBig ceiling2GiB=Ok 629145600 Minimized (-DZD_MAX_OUT=65536): gzip 60000 B (< 64 KiB ceiling): TooBig expected Ok 60000 raw 60000 B (< 64 KiB ceiling): TooBig ...
- Mechanism: corelib/compress/compress_shim.c@zx_decompress (lines 81-93) and @zx_raw_inflate (lines 140-148). When the output buffer fills, the loop computes 'ncap = cap * 2u' and returns ZD_TOOBIG if 'ncap > ZD_MAX_OUT'. It never grows to a size between the last doubling and ZD_MAX_OUT. The first buffer is 'len*4+64', so the effective limit is cap0*2^k for the largest k that keeps it at or below ZD_MAX_OUT, which lands anywhere in (ZD_MAX_OUT/2, ZD_MAX_OUT] depending on the input length. For the same reason, when cap0 is already above ZD_MAX_OUT (large inputs), no growth happens and output above the ceiling is accepted. The ceiling is checked against the growth target, never against s.total_out. ...
- Suggested fix: When cap*2 would exceed ZD_MAX_OUT but cap < ZD_MAX_OUT, clamp ncap = ZD_MAX_OUT and continue; only emit ZD_TOOBIG when the buffer is already at ZD_MAX_OUT and inflate still needs room. Then the effective ceiling equals the documented 1 GiB. The existing -DZD_MAX_OUT gate can pin a value just above and just below a target output to prove the boundary.
D51. datetime.offset_at accepts out-of-range and overflowing POSIX TZ offsets (returns 8044431881044758032) where glibc clamps and the docs say bad input answers 0
low, verified; compilers: both.
- Where: corelib/datetime/datetime_shim.c@ty_tz_off (unbounded h/m/s accumulation, wraps under -fwrapv) via ty_posix_tz_offset, which answers before the glibc fallback
- Observed: Original repro (tychoc): offset_at("EST5", 0) = -18000 offset_at("UTC0", 0) = 0 offset_at("garbage", 0) = 0 offset_at("EST25", 0) = -90000 offset_at("EST99999", 0) = -359996400 offset_at("EST5:99:99", 0) = -24039 offset_at("EST9999999999999999999", 0) = 8044431881044758032 offset_at("EST5EDT99999,M3.2.0,M11.1.0", 0) = -18000 glibc date +%z: EST5 -0500, EST25 -2400, EST99999 -2400, EST5:99:99 ...
- Mechanism: corelib/datetime/datetime_shim.c@ty_tz_off (lines 101-112) reads hh, mm and ss with unbounded 'x = x*10 + digit' loops into 'long'. It has no digit cap and no range check (POSIX: hh 0..24, mm/ss 0..59), and 'h*3600 + m*60 + s' wraps under -fwrapv. ty_posix_tz_offset (line 182) calls it for the std offset (line 187) and the DST offset (line 198). It accepts the result and returns 0 (success), so dtx_offset_at (line 276) returns the value before reaching the glibc setenv/tzset fallback, which would have clamped it. ty_tz_rule (line 137) reuses ty_tz_off for the rule '/time', which glibc does not limit to 24h, so a fix needs two separate bounds.
- Suggested fix: Cap the digits and reject hh > 24 or mm/ss > 59 (return NULL, so the caller answers 1 = unparseable).
D52. A handle destructor declared -> bytes/[int]/[float] passes the free: check, then the scope-exit call fails in cc with 'too few arguments'
low, verified; compilers: both.
- Where: the handle destructor check added by FRICTION 126 (src/tychoc.c resolve_program; compiler/ counterpart) checks arity and parameter type but not the return shape. gen_extern_proto lowers a bytes/array return to two extra out-params.
- Observed: tychoc: min/a0.c (line 3224 at b9df4aa):46: error: too few arguments to function 'hclose' (the close(h) site) min/a0.c (line 3225 at b9df4aa):14: error: too few arguments to function 'hclose' (the scope-exit 'if (h_h) hclose(h_h);') tychoc: C compilation failed (cc -O3 -fwrapv ...) rc=1 tychoc1: min/a1.c (line 3223 at b9df4aa):19: error: too few arguments to function 'hclose' min/a1.c (line 3224 ...
- Mechanism: The destructor check exists in both compilers and in both it tests only the parameters. In src/tychoc.c@resolve_program (the FFI-H loop around lines 10020-10038) it rejects only when 'fs->nparams != 1 || fs->params[0] != ht'; compiler/types/tcheck.ty (the handle loop around lines 3800-3825, same comment block) tests only 'len(ptypes) != 1 or ptypes[0] != "@"+hn'. Neither looks at the return type. src/tychoc.c@gen_extern_proto (line 13353) declares a bytes or [int]/[float] return (bret/aret) as a void C function with two trailing out-params, so its C arity is 3. The two free sites still pass only the handle: the scope-exit free in taskvar_push (src/tychoc.c (line 12394 at b9df4aa), 'if ...
- Suggested fix: Also refuse a destructor whose return is bytes, [int] or [float] (or emit the out-params at the free site). Add tests/reject/handle_free_bytes_ret.ty.
D53. docs/reference/ffi.md still says u8/u16/i8/i16/i32/i64 are plain int to Tycho and that to_i32 returns int; both compilers reject that
low, verified; compilers: both.
- Where: docs/reference/ffi.md lines 32-39 ('The narrower spellings ... are 'int' to Tycho — you pass and receive ordinary 'int' values') and the 'to_i32(n: int) -> int' paragraph. The stale comment above src/tychoc.c@ffi_sized_ctype says the same.
- Observed: min/param (tychoc and tychoc1 print the same): main.ty (line 5 at b9df4aa): error: argument type mismatch: argument 1 of 'abs' is int, expected i32 5 | println(str(abs(n))) min/ret (tychoc and tychoc1 print the same): main.ty (line 2 at b9df4aa): error: declared type int but value is i32 2 | x: int = to_i32(-1) Also seen: 'y := r + k' with r:i32 and k:int gives "arithmetic requires two ints or ...
- Mechanism: The docs were not updated when the sized family became first-class. Stale text: - docs/reference/ffi.md (line 32-39 at b9df4aa), the "Sized integers" bullet. - docs/reference/ffi.md (line 243-257 at b9df4aa), the 'to_i32(n: int) -> int' paragraph. - docs/reference/builtins.md (line 121 at b9df4aa), the 'to_i32(n) | int -> int' row. - docs/spec/14-ffi.md (line 126 at b9df4aa), the core:path "needs no guard" sentence. The compiler source has the same staleness, as dead code. src/tychoc.c@ffi_sized_ctype (line 4888) and its two call sites in parse_extern_fn (lines 4927-4933 and 4960-4962) still say "'int' to Tycho". Their guard is 'at(ps, TK_IDENT)', but the lexer at src/tychoc.c (line 523-528 ...
- Suggested fix: Rewrite the 'Sized integers' bullet and the to_i32 paragraph in docs/reference/ffi.md. The spec line 'core:path needs no guard: it is lexical and reaches no char*' is also stale (path.real calls realpath and does guard).
D54. httpd.framing fails open on a repeated Transfer-Encoding whose last value is empty (and on an empty Content-Length): request is framed as bodyless, the connection stays alive, and the body bytes are answered as a second request
high, verified; compilers: both.
- Where: corelib/httpd/httpd.ty@header (lines 233-239: last duplicate wins, returns "" for both absent and present-but-empty) used by @framing (lines 178-186: 'if header(r, "transfer-encoding") != ""' / 'clh == "" -> (0, true)'); the server's backup check server/main.ty@serve_once lines 535-549 uses the ...
- Observed: min/unit (identical under tychoc and tychoc1): TE: chunked framing=(0, false) TE: chunked + TE: (empty) framing=(0, true) TE: (empty) + TE: chunked framing=(0, false) TE: (empty) alone framing=(0, true) CL: (empty) framing=(0, true) CL: 5 + CL: (empty) framing=(0, false) min/smuggle.py (single write; the smuggled head at offset 4096 inside the chunk data), same for the tychoc and tychoc1 builds: ...
- Mechanism: corelib/httpd/httpd.ty@header (lines 233-239) keeps the value of the LAST matching key and returns "" both when the field is absent and when it is present but empty (parse_request trims values, so a whitespace-only value is also ""). corelib/httpd/httpd.ty@framing (lines 178-186) uses 'header(r, "transfer-encoding") != ""' as a presence test and treats 'clh == ""' as "no Content-Length -> (0, true)". So a trailing empty TE hides an earlier 'chunked', and an empty CL passes as no body. content_length_conflict only catches an empty CL when another non-empty CL is present. read_request_resume then sets need = head only, discards the rest of its buffer, and leaves any remaining body bytes on ...
- Suggested fix: Add a presence test, e.g. 'fn has_header(r, name) -> bool' that scans header_keys case-insensitively. In framing, refuse when any transfer-encoding key is present, and treat a present Content-Length whose trimmed value is "" as not framed (0, false). Use the same helper in server/main.ty's bad_len check. Pin it with a corelib/test/httpd case that sends 'TE: chunked' + 'TE:' and an empty ...
D55. core:http post/post_bytes: CR/LF in the content_type argument injects arbitrary request headers onto the wire
medium, verified; compilers: both.
- Where: corelib/http/http.ty@post and @post_bytes (they refuse only NUL in content_type), corelib/http/http_shim.c@perform lines 107-111 ('snprintf(h, sizeof h, "Content-Type: %s", ctype)' then curl_slist_append)
- Observed: tychoc and tychoc1 print the same thing: ok=true status=204 POST / HTTP/1.1<CRLF> Host: 127.0.0.1:38969<CRLF> User-Agent: tycho-corelib-http/1.0<CRLF> Accept: */*<CRLF> Content-Type: text/plain<CRLF> X-Injected: yes<CRLF> Content-Length: 1<CRLF> <CRLF> x Smuggling variant (post_bytes): nul ok=false split ok=true POST / HTTP/1.1<CRLF> Host: 127.0.0.1:41017<CRLF> User-Agent: ...
- Mechanism: corelib/http/http.ty@post and @post_bytes (lines 29-38) check content_type only with _has_nul. corelib/http/http_shim.c@perform (lines 107-111) then runs 'snprintf(h, 512, "Content-Type: %s", ctype)' and 'curl_slist_append', and libcurl writes each list entry to the wire verbatim. A CR/LF inside ctype therefore ends the Content-Type line and starts new header lines, or ends the whole head. The fixed 512-byte buffer also silently truncates a long content_type. Suggested fix: refuse CR, LF and other control bytes except HTAB, plus overlong values, in both Tycho wrappers, returning null as the NUL case does. Mirror the check in the shim so a direct http_post_bytes caller is covered too.
- Suggested fix: In http.ty post/post_bytes, refuse any content_type containing a byte < 0x20 other than HTAB, or 0x7f, returning null like the NUL case. Optionally mirror that in http_shim.c before curl_slist_append. Add a corelib/test/http case against a loopback listener asserting null plus no request sent.
D56. core:tls: the documented bounded-connect API (tls.connect_timeout, tls.set_timeout_ms) cannot be called from any program; the functions are package-private
medium, verified; compilers: both.
- Where: corelib/tls/tls.ty lines 57-66 ('fn _connect_timeout', 'fn _set_timeout_ms') vs the header comment lines 12-17; CHANGELOG.md (line 377 at b9df4aa); surface.lock (lists only close_conn/connect/ok/read/write)
- Observed: .../min/tls/main.ty (line 5 at b9df4aa): error: package 'tls' has no symbol 'connect_timeout'; did you mean '_connect_timeout'? 5 | println(str(tls.ok(tls.connect_timeout("127.0.0.1", 1, 200)))) rc=1 (tychoc and tychoc1 identical) Original repro, variant b: error: 'tls._connect_timeout' is package-private: a leading-underscore name is not accessible from another package (both compilers, rc=1) ...
- Mechanism: corelib/tls/tls.ty@_connect_timeout and @_set_timeout_ms (lines 59 and 65): commit f6f1025a gave them a leading underscore, so the rule in src/tychoc.c@check_pkg_private (mirrored in tychoc1) makes them package-private. That sweep (FRICTION 96, option 2) treated "no qualified call from outside the package, and absent from docs/reference/corelib.md" as meaning "internal helper". Wrappers that had only just shipped (0.8.5) had no in-tree external caller and no catalogue entry, so they were misclassified. Nothing inside package tls calls them, so they are now unreachable dead code. The header comment (tls.ty (line 12-17 at b9df4aa)) was not updated, and no test in corelib/test/tls calls them ...
- Suggested fix: Rename to connect_timeout / set_timeout_ms, add them to surface.lock, and add a corelib/test/tls leg calling them from package main. If they are meant to stay private, correct the tls.ty header and the CHANGELOG line instead. Note for whoever makes them public: tls_shim.c@tcp_connect_timed passes '(int)ms' to poll with no upper bound (net.accept_wait refuses ms > INT_MAX), so bound ms first.
D57. httpd.parse_request accepts header and request-line grammar that RFC 9112 requires rejecting: whitespace before the colon, an obs-folded continuation line honoured as its own field (both give a real Content-Length), and a request line with more than three tokens or any version string
low, verified; compilers: both.
- Where: corelib/httpd/httpd.ty@parse_request lines 216-230 ('split(..., " ")' with only 'len(parts) < 3' checked; header name is 'trim(ln[0:col])', which drops leading and trailing whitespace)
- Observed: (identical under tychoc and tychoc1) ws-before-colon: Ok method=POST path=/ version=HTTP/1.1 framing=(3,true) obs-fold : Ok method=POST path=/ version=HTTP/1.1 framing=(3,true) 4-token line : Ok method=GET path=/ version=HTTP/1.1 framing=(0,true) bogus version : Ok method=GET path=/ version=FOO/9.9 framing=(0,true) control 3-tok : Err live tycho-httpd: ws-before-colon CL, no body sent -> ...
- Mechanism: The defect is in corelib/httpd/httpd.ty@parse_request, around lines 219-228, in three places: - Request line: 'parts := split(_chomp(lines[0]), " ")' is checked only with 'if len(parts) < 3'. Extra tokens are silently dropped, and parts[2] is never checked as a version. - Header names: 'col := find(ln, ":")' then 'push(keys, trim(ln[0:col]))'. trim() (httpd.ty@trim) strips SP/HTAB from both ends, so "Content-Length " and " Content-Length" both become "Content-Length". - Continuation lines: a line starting with SP/HTAB (obs-fold) is never recognised, so it is parsed as a new field. framing() and header() then act on that forged key, and read_request_resume waits for that many ...
- Suggested fix: In parse_request, return Err(Malformed) when a header line starts with SP/HTAB, when the name (ln[0:col]) is not a non-empty RFC token (i.e. contains SP/HTAB), when len(parts) != 3, or when parts[2] is not HTTP/1.0 or HTTP/1.1. Add corelib/test/httpd cases for each.
D58. tycho-httpd Range: a position with leading zeros that is 16+ characters long is treated as 10^15, giving a false 416 for a satisfiable range and the whole file for a small suffix
low, verified; compilers: both.
- Where: server/main.ty@parse_pos lines 192-197 ('if len(s) > 15: return HUGE_POS' before any leading-zero strip)
- Observed: min/repro.sh, identical for the tychoc and tychoc1 builds: bytes=1- 206 bytes 1-1/2 bytes=0000000000000001- 416 bytes */2 bytes=-1 206 bytes 1-1/2 bytes=-0000000000000001 206 bytes 0-1/2 Full probe on the 5000-byte blob, both builds: bytes=0000000000000001- (16 digits) -> 416 Range Not Satisfiable bytes */5000 bytes=000000000000001- (15 digits) -> 206 bytes 1-4999/5000 bytes=-0000000000000010 (16 ...
- Mechanism: server/main.ty@parse_pos (lines 192-197). 'if len(s) > 15: return HUGE_POS' is applied to the raw digit string before any leading zeros are stripped. A 16+ character position whose true value is small is therefore mapped to 1e15. parse_range then routes it to the "past EOF" arm (416 for first-pos) or the "longer than the file" arm (whole file for a suffix, clamp to EOF for last-pos). corelib/httpd/httpd.ty@parse_len strips zeros first; parse_pos lacks that step. This is a source-level bug in the server itself, so both compilers are affected equally.
- Suggested fix: Strip leading zeros in parse_pos (keeping at least one digit) before the len > 15 test, as parse_len does. Add the two cases above to server/run.sh.
D59. core:net shim truncates 64-bit fds to int after checking only 'fd < 0', so net.close_fd(fd + 2^32) closes an unrelated live descriptor instead of failing closed
low, verified; compilers: both.
- Where: corelib/net/net_shim.c: netx_close (line 254-256), and the same '(int)fd' pattern in netx_accept, netx_port_of, netx_peer_addr, netx_write, netx_read, netx_set_read_timeout, netx_udp_*, netx_accept_wait, and netx_poll_readable's fds[i] (line 443/448)
- Observed: Original repro (both compilers, identical): listener fd 3, passing 4294967299 port_of(listener) before: Ok port_of(listener) after close_fd(fd + 2^32): Err Minimized (min/close, identical under tychoc and tychoc1; the -1 is the control): after close_fd(-1): Ok after close_fd(srv + 2^32): Err min/poll (identical under both; the port number varies): port_of(srv + 2^32) = 37045 wait_readable([srv + ...
- Mechanism: corelib/net/net_shim.c: every fd entry point guards only 'fd < 0' and then calls the syscall with '(int)fd'. That covers netx_close ('if (fd >= 0) TY_CLOSE((int)fd);', line 255), netx_accept (121-122), netx_port_of (144-147), netx_peer_addr (165-168), netx_write (184-187), netx_read (214-218), netx_set_read_timeout (239-245), netx_udp_send (278-281), netx_udp_read (291-294) and netx_accept_wait (355/366/376). netx_poll_readable checks 'fds[i] < 0' (line 443), stores 'pf[i].fd = (int)fds[i]' (448), and then returns the un-narrowed fds[i] (462). A tycho_int above INT_MAX wraps modulo 2^32 on gcc/clang, so fd + k*2^32 aliases a live descriptor. The fix follows ...
- Suggested fix: Add a 'ty_fd_ok(tycho_int fd)' helper (0 <= fd <= INT_MAX) and use it in every netx_* entry and on each fds[i] in netx_poll_readable. Pin it with a corelib/test/net leg that calls close_fd(listener + 4294967296) and checks port_of still answers Ok.
D60. Cross-package "did you mean" suggests a package-private (_-prefixed) symbol, which the next compile then refuses
low, verified; compilers: both.
- Where: the unknown-package-member suggestion path in src/tychoc.c and compiler/**/*.ty (the candidate list is not filtered by the leading-underscore visibility rule)
- Observed: min (tychoc and tychoc1 identical): .../verify/net-6/min/main.ty (line 5 at b9df4aa): error: package 'lib' has no symbol 'helper'; did you mean '_helper'? 5 | println(str(lib.helper(1))) min_follow (both): .../min_follow/main.ty (line 5 at b9df4aa): error: 'lib._helper' is package-private: a leading-underscore name is not accessible from another package shadow (both; lib also exports 'parsed'): ...
- Mechanism: tychoc: src/tychoc.c@suggest_pkg_symbol. The loop over g_sigs takes every 'prefix__*' entry as a candidate, both for the edit-distance arm (dym) and for the prefix-related 'rel' arm. It never skips a tail whose first character is '_', despite the "closest EXPORTED symbol" comment above the function. Its two callers are both affected: the E_CALL qualified-call branch ('package '%s' has no symbol ...; did you mean', near line 7197) and the E_FIELD package-value branch ('has no variant, const or function', near line 6856). tychoc1: compiler/types/resolve.ty@_pkg_cands, a faithful port of the same routine, which also has no '_' filter. It feeds _pkg_dym, which is called from both the ast.Call ...
- Suggested fix: When the reference crosses a package boundary, drop '_'-prefixed candidates from the suggestion set in both compilers, and add a tests/diag fixture pinning the message for 'httpd.ascii_lower'.
D61. core:toml silently drops data from VALID documents: a parent table declared after its subtable, and subtables of arrays-of-tables
high, verified; compilers: both.
- Where: corelib/toml/toml.ty@_place (the len(path)==1 branch replaces an existing table; an intermediate segment that holds a TArr is replaced by a fresh table)
- Observed: Original repro, identical from tychoc and tychoc1: Ok {a={y=2}} Ok {fruit=[{name="banana"}]} Ok {a={b=[{x=2}]}} tomllib: {'a': {'b': {'x': 1}, 'y': 2}} / {'fruit': [{'name': 'apple', 'physical': {'color': 'red'}}, {'name': 'banana'}]} / {'a': [{'b': [{'x': 1}]}, {'b': [{'x': 2}]}]} Minimized, both compilers: a.b.x LOST fruit has 1 element(s) Wider probe, both compilers (tomllib answer in ...
- Mechanism: corelib/toml/toml.ty@_place (lines 112-159), with parse (lines 555-624) placing a section only when the next header arrives. There are three faults. (1) Terminal table branch, lines 139-140: 'if idx >= 0: vs[idx] = tbl' overwrites whatever is already in the slot. That can be an implicit super-table built by an earlier [a.b], a scalar, or an array-of-tables, and it is never merged or refused. (2) Terminal array branch, lines 126-131: an existing non-TArr value, such as a table from [a], falls into the '_:' arm, becomes an empty array, and replaces the table. (3) Intermediate segment, lines 148-152: a TArr (or scalar) value falls into the '_:' arm and becomes a fresh TTable that replaces it. ...
- Suggested fix: In _place, when the terminal slot is an implicitly created TTable, merge into it and refuse duplicate keys; when the slot holds a scalar, or an explicitly defined table, refuse. For an intermediate TArr, recurse into the last element and write the array back. Add the three documents above to corelib/test/toml with their tomllib answers.
D62. path.resolve_under accepts a DANGLING symlink inside the root, so a later write lands outside the root
medium, verified; compilers: both.
- Where: corelib/path/path.ty@resolve_under (the ancestor walk, lines 216-229) + corelib/path/path_shim.c@pathx_real
- Observed: == tychoc dangling: [(scratch) existing: [] == tychoc1 dangling: [(scratch) existing: [] (The original write repro also reproduces with both the p and p1 builds: "resolve_under -> .../root/avatar.png", "wrote .../root/avatar.png", rc=0, and outside/planted.txt is created containing "attacker bytes". The control outlink/other.txt gives "refused", rc=1.)
- Mechanism: corelib/path/path_shim.c@pathx_real returns "" for every realpath(3) failure (line 46: 'if (!realpath(p, tmp)) return buf;'), so the ENOENT from a dangling link's missing target looks the same as a path that does not exist. corelib/path/path.ty@resolve_under (lines 216-229) treats real(p)=="" as "component absent" and walks up with dir(p) until real(root) succeeds. under(rb, rb) is true, and p != j, so it returns the lexical join j (line 225: 'return j'). The caller's open/fopen then follows the symlink, which the ancestor walk never inspected. The lexical-join branch never lstat()s the missing tail to confirm that each component really does not exist. Both compilers share this corelib .ty ...
- Suggested fix: When realpath fails, lstat() the path in the shim (or add a pathx_is_link extern). If any component of the missing tail exists as a symlink, refuse. Alternatively resolve only the parent directory, then require that the leaf either does not exist under lstat or is not a symlink. Pin the fix with a fixture that plants a dangling link and expects "".
D63. core:toml can be crashed and memory-exhausted by small inputs: no nesting cap, and quadratic time and memory in keys and headers
medium, verified; compilers: both.
- Where: corelib/toml/toml.ty@_parse_array and corelib/toml/toml.ty@_parse_value (unbounded recursion, and an O(n) trim/_split_items copy per level); @_assign/@_place/@parse (every key or header rebuilds the whole table; 'seen' is a linear list)
- Observed: tychoc build: keys 1000: rc=0 t=0.04s maxrss=25 MB | keys 4000: rc=0 t=0.50s maxrss=379 MB (input 38,890 B) | hdr 1000: 67 MB | hdr 4000: rc=0 t=1.57s maxrss=1050 MB (input 54,890 B) | deep 1000: 53 MB | deep 4000: rc=0 t=0.92s maxrss=423 MB (input 8,005 B) | deep 20000: rc=1 t=2.74s maxrss=2083 MB err=b'tycho: stack overflow -- recursion too deep\n' tychoc1 build: keys 4000 379 MB, hdr 4000 1050 ...
- Mechanism: corelib/toml/toml.ty has three causes: (1) @_assign (lines 76-108) takes 'ks = k' and 'vs = vv' out of the table and returns 'TTable(ks, vs)'. Under the spec §9.2 deep-copy-on-assignment rule, every key line (parse, line 618: 'cur = _assign(cur, kp, v)') copies the whole current table. The copies stay in parse's arena, so N keys cost O(N²) bytes. @_has_path/@index_of also add an O(N) scan per key. (2) @_place (lines 112-159) works the same way, once per header (parse, line 597: 'root = _place(root, cur_path, cur, cur_arr)'). It deep-copies root and every section placed so far, so N headers cost O(N²) bytes. The 'seen' duplicate-header list (lines 562 and 590) is searched with a linear ...
- Suggested fix: Add a depth parameter to _parse_array/_parse_value and return Err past about 128 levels. Parse arrays with an index cursor instead of copying substrings. Build tables in place: keep an index map from key to slot while parsing, and turn 'seen' into a map.
D64. zip.create silently wraps a name length >= 64 KiB and an entry count > 65535 (to_u16/to_u32), writing archives whose own reader lists forged entries and hides real ones
medium, verified; compilers: tychoc.
- Where: corelib/zip/zip.ty@create (to_u16(len(e.name)) in Local and Central, to_u16(len(entries)) in Eocd, and to_u32 for the sizes and offsets)
- Observed: == tychoc created 2, listed 1 name len 1 extract len 0 wrote .../min/name/out_tychoc.zip created 65536, listed 0 == tychoc1 created 2, listed 1 name len 1 extract len 0 wrote .../min/name/out_tychoc1.zip created 65536, listed 0 (cmp: the tychoc and tychoc1 archives are identical; the local header's nlen field reads 1 in a 131297-byte archive) python zipfile: BadZipFile Bad magic number for ...
- Mechanism: corelib/zip/zip.ty@create. The Local and Central headers write nlen as to_u16(len(e.name)), but _push_str(&out, e.name) then appends all len(e.name) bytes. A reader (zip.list/_entry_body, and Python) advances by nlen, so everything past byte nlen of the name is parsed as the next record. The Eocd writes n_here/n_total as to_u16(len(entries)), which wraps at 65536. csize/usize/off/cd_size/cd_start use to_u32, which would wrap at 4 GiB (not tested; unreachable in practice because out is a []int). Nothing checks any of these ranges, and ZIP64 is not implemented. Both compilers are affected because the defect is in corelib source, not in codegen.
- Suggested fix: Change create to return Result(bytes, string), or add create_checked, and refuse len(name) > 65535, len(entries) > 65535, and any size or offset > 0xFFFFFFFF. As a minimum, die with a clear message instead of wrapping.
D65. core:compress shim truncates lengths >= 4 GiB to uInt: compress/raw_compress silently encode only len mod 2^32 bytes and report success
medium, verified; compilers: n/a.
- Where: corelib/compress/compress_shim.c@zx_compress and @zx_raw_deflate ('s.avail_in = (uInt)len'); the same cast is in zx_decompress/zx_raw_inflate
- Observed: shim (min.c): in=4294967296 compressed=20 decompress_status=0 out=0 in=4294967312 compressed=23 decompress_status=0 out=16 original repro (shimtest.c): gzip: input 4294967312 bytes -> compressed 23 -> decompress status 0, 16 bytes raw: input 4294967312 bytes -> compressed 5 -> inflate status 0, 16 bytes Tycho level (identical for the tychoc-built and tychoc1-built binary; peak RSS 8188056 kB): in ...
- Mechanism: corelib/compress/compress_shim.c@zx_compress (line 34, 's.avail_in = (uInt)len;') and @zx_raw_deflate (line 116, the same cast). z_stream.avail_in is a 32-bit uInt, so only len mod 2^32 bytes are offered, and the single deflate(Z_FINISH) call returns Z_STREAM_END on that prefix. The shim treats Z_STREAM_END as success, and the gzip trailer (CRC32 and ISIZE of the prefix) is self-consistent, so zx_decompress accepts the result. The (uInt) casts in zx_decompress and zx_raw_inflate (lines 71/73/92, 131/132/148) exist too, but they only ever fail closed (Truncated, TooBig or Failed). Fix: in both deflate functions, loop and feed avail_in and avail_out in slices of at most UINT_MAX until ...
- Suggested fix: Loop over the input in slices of at most UINT_MAX, topping up avail_in and avail_out and calling deflate/inflate until Z_STREAM_END. Alternatively refuse len > UINT_MAX up front with *out=NULL, and do the same in both inflate paths.
D66. core:toml mis-parses quoted keys and quoted headers, and invents values from malformed literal strings and lone surrogate escapes
medium, verified; compilers: both.
- Where: corelib/toml/toml.ty@_parse_key and @split_path (split on '.' before unquoting; headers never unquoted); @_parse_value literal branch (checks only the first and last byte); @_parse_basic \u path (utf8.encode returns "" for a surrogate)
- Observed: Original repro, rebuilt with each compiler; both print the same lines: Ok {"a={b"=1}} Ok {"x"={y=1}} Ok {a={x=1},"a"={y=2}} Ok {x="a' 'b"} Ok {x="it's"} Ok {x="ab"} Ok {a]={x=1}} Minimized repro (min/main.ty), same output from tychoc and tychoc1: site keys: ["google] root keys: ["x"] dup accepted, root keys: [a, "a"] x = [a' 'b] x = [ab] What tomllib returns for the same inputs: site."google.com" ...
- Mechanism: corelib/toml/toml.ty@_parse_key (lines 543-553): it splits the key on "." with split(key, ".") before it removes quotes. A dot inside quotes therefore splits the key, and a fragment like '"google' still has one quote on it. It also only removes the outer quote bytes and never decodes escapes in a quoted basic key. corelib/toml/toml.ty@split_path (lines 531-538), used for headers at line 583: it splits on "." and never removes quotes. '["x"]' names a table '"x"', and the duplicate-header check at lines 587-592 compares the spellings 'a' and '"a"' as different keys. corelib/toml/toml.ty@_parse_value, literal branch (lines 169-172): it only checks that the token starts and ends with a ''' ...
- Suggested fix: Tokenize keys and headers with a quote-aware scanner shared by both. For literal strings, find the first closing ' and refuse anything after it. In \u and \U escapes, refuse surrogates and code points above 0x10FFFF (and add \U).
D67. core:json parse is quadratic in the number of members of one object (the duplicate-key scan)
medium, verified; compilers: tychoc.
- Where: corelib/json/json.ty@_parse_object -> @_key_index (a linear scan of every earlier key, for each member)
- Observed: Original repro, tychoc: rc=0 t=0.44s maxrss=10 MB out=b'Ok, 20000 members\n' rc=0 t=1.88s maxrss=10 MB out=b'Ok, 40000 members\n' rc=0 t=7.11s maxrss=14 MB out=b'Ok, 80000 members\n' rc=0 t=0.28s maxrss=44 MB out=b'Ok, 160000 members\n' (arr160000 control) Minimal repro, tychoc: 127781 bytes, 10000 members obj 10000: .128962747s 277781 bytes, 20000 members obj 20000: .506427305s 577781 bytes, ...
- Mechanism: corelib/json/json.ty@_parse_object (line 538) calls corelib/json/json.ty@_key_index (lines 505-509). For each member, _key_index does a linear 'for i' scan comparing the new key against every key already pushed into the object's 'ks' array. An object with n members therefore costs n(n-1)/2 string comparisons. Replacing just that lookup with a per-object '[]string: int' key-to-slot map makes the parse linear and keeps the last-wins-at-first-position behavior.
- Suggested fix: While parsing each object, keep a []string: int map from key to slot and look duplicates up in it, preserving last-wins at the first position.
D68. markdown.inline is still O(n^2) when the terminator EXISTS: '['*n + ']' (the no_rb memo only caches absence)
medium, verified; compilers: tychoc.
- Where: corelib/markdown/markdown.ty@_inline_at (the '[' and '![' arms: 'rb = _index_from(s, 93, i + 1)' rescans to the ']' for every '['; the same for ')')
- Observed: p0 n=20000 tail=x len=20001 ms=3 p0 n=20000 tail=] len=20001 ms=130 p0 n=40000 tail=x len=40001 ms=3 p0 n=40000 tail=] len=40001 ms=630 p0 n=80000 tail=x len=80001 ms=15 p0 n=80000 tail=] len=80001 ms=2480 p1 n=20000 tail=x len=20001 ms=6 p1 n=20000 tail=] len=20001 ms=281 p1 n=40000 tail=x len=40001 ms=18 p1 n=40000 tail=] len=40001 ms=1262 p1 n=80000 tail=x len=80001 ms=8 p1 n=80000 tail=] ...
- Mechanism: corelib/markdown/markdown.ty@_inline_at, in the '![' arm (lines 106-110) and the '[' arm (lines 132-136). 'rb = _index_from(s, 93, i + 1)' runs again for every '[' while 'no_rb' is false. no_rb memoizes only absence, so a found ']' is thrown away. When that ']' does not form a link (no '(' after it, or no ')' anywhere, so no_rp is set), the arm emits a literal '[' and moves i forward by 1. The next '[' then scans all the way to the same ']' again, giving n scans of O(n) each. rp, backtick, '**', '*' and '_' do not have this problem, because a found terminator is always consumed (i jumps past it). The fix is to cache the found index, e.g. 'next_rb', reuse it while next_rb > i, and rescan ...
- Suggested fix: Cache the found index as well, 'next_rb', and reuse it while i < next_rb, recomputing only once i passes it (the same for next_rp and backticks). Pin both the '['*n+']' and the '!['*n+']' shapes with a timing-free bound, such as a count of calls or bytes scanned.
D69. core:zip: every extract() re-reads the whole central directory and scans backwards over the whole file for the EOCD, so reading all entries is quadratic, and trailing junk multiplies the cost
medium, verified; compilers: tychoc.
- Where: corelib/zip/zip.ty@extract (calls list(), then _entry_body walks the CD again) and @_find_eocd (scans every offset from the end, not only the last 65557 bytes)
- Observed: tychoc (p0): z2000 0.36s | z4000 1.06s | z8000 3.91s | j0 (200 entries) 0.01s | j100 (100 entries + 2MB zeros) 5.39s | j200 (200 entries + 2MB zeros) 13.67s | j200 list-only 0.065s tychoc1 (p1): z2000 0.32s | z4000 1.30s | z8000 5.28s | j0 0.02s | j100 7.02s | j200 14.41s | j200 list-only 0.068s Original repro, re-run (tychoc): many4000 1.02s, many8000 4.71s, many16000 18.20s, few200 0.01s, ...
- Mechanism: corelib/zip/zip.ty@extract (l.126-130) finds the Meta by a linear pass over list(b), which is a full _find_eocd plus a CD walk. It passes only the Meta, which carries no local-header offset, to corelib/zip/zip.ty@_entry_body (l.132-176). _entry_body calls _find_eocd again (l.134) and walks the CD again, comparing names, to recover c.off. corelib/zip/zip.ty@_find_eocd (l.115-122) scans from len-22 down to 0 and builds from_bytes$(Eocd) over a 22-byte slice at every offset, with no 65557-byte bound. So one extract costs O(CD) + O(bytes after the EOCD), and extracting every entry costs O(N^2 + N*junk). Fix: carry the local offset in Meta (or add extract_entry(b, meta) or an iterator) and bound ...
- Suggested fix: Store the local-header offset in Meta, and add extract_entry(b, meta) (or an iterator) that seeks directly. Bound _find_eocd to the last 65557 bytes and check that the EOCD's comment length reaches exactly to the end of the file.
D70. compress.decompress returns only the FIRST gzip member and accepts trailing garbage, as Ok
medium, verified; compilers: both.
- Where: corelib/compress/compress_shim.c@zx_decompress (stops at the first Z_STREAM_END and never checks avail_in)
- Observed: tychoc: two-members Ok 6 [first|] trailing-junk Ok 6 [first|] tychoc1: two-members Ok 6 [first|] trailing-junk Ok 6 [first|] Cross-check on the same a+b bytes written to disk (26+27=53 bytes): python gzip.decompress gives b'first|second|'. zlib.decompressobj(31).decompress gives b'first|' with unused_data len 27. gzip -dc gives first|second|. Original repro, re-run: 'Ok 11 bytes: first half' for ...
- Mechanism: corelib/compress/compress_shim.c@zx_decompress, line 76. 'if (rc == Z_STREAM_END) break;' exits the inflate loop at the end of the first gzip/zlib member. The code then sets *status = ZD_OK and returns total_out without checking s.avail_in. zlib's inflate() never crosses a member boundary by itself; multi-member decoding needs an inflateReset loop, as zlib's own gzread has. So any bytes after the first member, whether a valid second member or junk, are dropped and the call reports Ok. Both compilers link the same shim, so both are affected. zx_raw_inflate has the same shape (it also ignores avail_in after Z_STREAM_END), but its only caller, core:zip, passes an exact csize slice.
- Suggested fix: After Z_STREAM_END, if avail_in > 0 and the remaining bytes begin with the gzip magic 1f 8b, inflateReset and continue appending. Otherwise, if avail_in > 0, return ZD_CORRUPT. For raw_decompress (zip) the trailing bytes can stay ignored, or be checked against csize.
D71. The checkpoint rewrites the store in place, so a crash, kill -9 or full disk during it destroys every committed row
high, verified; compilers: both.
- Where: tools/tycho-db/store/store.ty@flush (io.write_bytes, i.e. corelib/io/io_shim.c@iox_write_bytes, which does fopen(path,"wb"): truncate, then write), called from tools/tycho-db/wal/wal.ty@checkpoint. The checkpoint runs unconditionally at the end of every CLI run (main.ty@run_script), read-only runs ...
- Observed: == A: SIGKILL at the checkpoint's first write(2) to the store (read-only SELECT) seeded: store 76940 B, log 17 B killed rc=137 after: store 0 B, log 17 B f.db is unreadable and its log no longer reaches back past lsn 3001 reopen rc=1 == B: the same read-only SELECT, write capped at 32 KiB, SIGXFSZ ignored (a plain EFBIG, no signal) cannot write f.db rc=1 after: store 32768 B, log 17 B f.db is ...
- Mechanism: The defect is the ordering in tools/tycho-db/wal/wal.ty@checkpoint (lines 235-252). It calls store.flush (tools/tycho-db/store/store.ty@flush, line 366), which goes through io.write_bytes to corelib/io/io_shim.c@iox_write_bytes: 'fopen(path, "wb")' (O_TRUNC), then fwrite. The only durable copy of every row at or below the log's base_lsn is truncated before its replacement is written. The previous checkpoint already rewound the log header to base_lsn > 0, so a store left short or empty sends open_db (wal.ty line 283) into LostPrefix, which refuses the database permanently. The fsync guard at line 244 protects only the later log rewind, not this truncation. Two call sites widen the window: - ...
- Suggested fix: Add io.rename (rename(2), or MoveFileEx on Windows) to core:io. store.flush should write '<path>.tmp', io.sync it, rename it over the path, then io.sync the directory. Skip the checkpoint when no record was appended since open (lg.next_lsn == db.applied_lsn). Pin the fix with a run.sh leg that runs one INSERT under 'ulimit -f' and asserts that every seeded row survives.
D72. tychoc1 drops the in-place append for 'bytes' accumulators and for accumulators declared in a match arm (tychoc keeps both), so tycho-db's torn-log recovery needs 7.5 GB for a 789 KB log
high, verified; compilers: tychoc1.
- Where: compiler/emit/emit.ty (line 5738 at b9df4aa) 'if t == "string" and _isacc(&cs, n)': a local declared as bytes never gets the _len_/_cap_ accumulator. compiler/emit/emit.ty@_acc_scan (7560-7615) recurses into IfS/WhileS/ForIn/For3 but not into match or select arms. The reference is ...
- Observed: str p0: rc=0 wall=0.00s maxrss=10MB out=b'320001' str p1: rc=0 wall=0.01s maxrss=10MB out=b'320001' bytes p0: rc=0 wall=0.00s maxrss=10MB out=b'320001' bytes p1: rc=0 wall=2.30s maxrss=3087MB out=b'320001' arm p0: rc=0 wall=0.01s maxrss=9MB out=b'320001' arm p1: rc=0 wall=2.19s maxrss=3087MB out=b'320001' armouter p0: rc=0 wall=0.01s maxrss=10MB out=b'320001' armouter p1: rc=0 wall=2.09s ...
- Mechanism: Two independent gaps in compiler/emit/emit.ty, each diverging from src/tychoc.c. (a) The bytes gap is at emit.ty (line 5738 at b9df4aa), the ast.Decl branch of the statement emitter: 'if t == "string" and _isacc(&cs, n):'. This is the only place the _len_/_cap_ shadow and cs.vacc are set up, so a local declared as 'bytes' never becomes an in-place accumulator. tychoc's is_self_append (src/tychoc.c (line 10693 at b9df4aa)) explicitly accepts T_BYTES as well as T_STRING. (b) The match-arm gap is in emit.ty@_acc_scan (7560-7615). It recurses into IfS, WhileS, ForIn and For3 only, not ast.MatchS(l, c, arms) or ast.SelectS(l, arms) (compiler/ast/ast.ty (line 161-162 at b9df4aa)). The result: a ...
- Suggested fix: At emit.ty (line 5738 at b9df4aa), accept 't == "string" or t == "bytes"' (the append emitter at ~5832 already handles bytes terms). Add match and select arm recursion to _acc_scan, mirroring collect_accums. Add a parity fixture per shape that bounds peak RSS or arena high-water under both compilers.
D73. KEY index maintenance is quadratic in time and memory: 689 KB of INSERTs over TCP grows the server to 1.6 GB, and reopening a 560 KB store takes 3.8 GB
medium, verified; compilers: both.
- Where: tools/tycho-db/store/store.ty@_place: the shift loop 'ix.ents[k] = ix.ents[k - 1]'. It is reached from store.insert (every INSERT, including the live server's long-lived Db) and from _build_index (every open). The emitted C for each shift is 'tycho_copy_S_store__IdxEnt(_ina_h_ix, ...)' into the ...
- Observed: Original repro, tychoc1 build of b9df4aa: key asc n=10000 insert script: rc=0 wall=31.64s maxrss=10MB nokey random n=10000 insert script: rc=0 wall=40.75s maxrss=10MB key random n=5000 insert script: rc=0 wall=22.45s maxrss=100MB key random n=10000 insert script: rc=0 wall=38.91s maxrss=389MB key random n=10000 reopen store (228940 bytes): rc=0 wall=0.52s maxrss=386MB n=10000: sent 338932 bytes, ...
- Mechanism: tools/tycho-db/store/store.ty@_place (lines 323-332), reached from store.insert (line 409, into the Db's owning arena '_ina_h_db') and from _build_index (line 306, on every decode/open). It inserts into the sorted 'ix.ents' by push and then shifts every later entry up one with 'ix.ents[k] = ix.ents[k - 1]'. Store.Val is a boxed enum ('E_store__Val *'). Each shift is emitted as '*tycho_arr_K8_ptr(&ix.ents, k) = tycho_copy_S_store__IdxEnt(_ina_h_ix, tycho_arr_K8_get(ix.ents, k-1))', which deep-copies the Val box, about 16 bytes, into the index's owning arena. The evicted box is never recycled, because runtime element-overwrite recycling covers only '[string]' (value-semantics-limits.md §2). A ...
- Suggested fix: _build_index should collect (key,row) pairs and sort once. insert should find the slot by binary search and place the entry with a single memmove-style insert that does not re-copy every element (or keep the index as sorted row ids and compare through rows). Add a gate leg that inserts 20k shuffled keys and bounds peak RSS.
D74. A mutation whose WAL append fails is still applied in memory: the client gets 'err cannot write', yet the row is served and checkpointed, and after a crash the database can refuse to open
medium, verified; compilers: both.
- Where: tools/tycho-db/exec/exec.ty@_create and @_insert: store.create_table and store.insert mutate &db before wal.append, and nothing rolls back on Err(Wal(e)).
- Observed: --- 1. ERR'd CREATE is still visible (expect: ERR no such table: t) cannot write nodir/x.db db nodir/x.db (0 table(s)) CREATE TABLE t (a INT); ERR cannot write nodir/x.db.wal SELECT * FROM t; cols a ok 0 row(s) from 0 examined rc=1 --- 2. ERR'd CREATE + logged INSERT + crash => store refuses to open Killed run rc=137 -rw-r--r-- 1 root root 512 Sep 25 18:37 b.db.wal no such table: t reopen rc=1 ...
- Mechanism: The bug is in tools/tycho-db/exec/exec.ty, in @_create (lines 100-105) and @_insert (lines 113-114). store.create_table and store.insert mutate '&db' (they push to db.tables, rows and index) before wal.append runs. When the append returns Err(Wal(NotWritten)), 'or_else e: Err(Wal(e))' returns the error, but nothing undoes the in-memory change. That breaks the invariant that memory equals store plus log. Three consequences follow. (a) Later statements see the refused change. (b) wal.checkpoint (wal/wal.ty@checkpoint, store.flush at line 237) writes it into the store file. (c) If the refused statement was a CREATE, a later INSERT into that table is logged. After a crash, wal.ty@open_db (line ...
- Suggested fix: Validate without mutating first: the column-count, type and existence checks can move out of store.insert and store.create_table. Then append to the log, then apply. Alternatively, pop the row and its index entries, or remove the table, when wal.append returns Err. Extend the NotWritten leg to three statements and assert the SELECT reports 'no such table: t'.
D75. Nothing locks the store: two processes on one database silently lose acknowledged commits (300 of 300 in one run)
medium, verified; compilers: both.
- Where: tools/tycho-db/wal/wal.ty@open_db, @append (write_at at the process-private lg.end) and @checkpoint (a whole-store overwrite from the process's own in-memory view). main.ty takes no exclusive open in CLI or --serve mode.
- Observed: Original repro (run twice, once per build): tdb1: writer A rc=0 acks=300; writer B rc=0 acks=300 / ok 0 row(s) from 300 examined (A) / ok 300 row(s) from 300 examined (B) tdb0: writer A rc=0 acks=300; writer B rc=0 acks=300 / ok 300 row(s) from 301 examined (A) / ok 1 row(s) from 301 examined (B) Server case (both builds): CLI: 0 [' ok 1 row into t', ' ok 1 row into t'] ... final SELECT: row srv ...
- Mechanism: tools/tycho-db/wal/wal.ty@open_db takes no exclusive ownership of the store or its log, and main.ty@main and main.ty@serve both call it unconditionally. Each process then works from a private in-memory store.Db and a private Log.end/next_lsn. wal.ty@checkpoint calls store.flush (store.ty@flush: io.write_bytes(db.path, encode(db)), a whole-file overwrite from this process's view), then rewrites the log header with io.write_bytes(lg.path, _header(lg.next_lsn)). Whichever process checkpoints last therefore erases every row another process committed and checkpointed after this one opened. In the deterministic repro, B's checkpointed rows are overwritten by A's later checkpoint. In the racy ...
- Suggested fix: Take an exclusive lock for the process lifetime in open_db (flock on '<store>.lock', or an O_CREAT|O_EXCL pid file with stale-lock handling) and refuse to open when it is held. This may need a small core:io lock primitive.
D76. store.decode trusts row arity, value types and trailing bytes: a one-byte corruption panics the process mid-query ('index 0 out of bounds'), and others silently change rows
medium, verified; compilers: both.
- Where: tools/tycho-db/store/store.ty@decode / take_val / take_col. nvals is never compared to ncols, a value's tag is never compared to its column type, trailing bytes after the last table are ignored, and a T_POS magnitude >= 2^63 wraps negative. The panic fires in _build_index (rows[r][c]) or in ...
- Observed: --- key0 tycho: index 0 out of bounds (len 0) rc=1 --- plain0 tycho: index 0 out of bounds (len 0) db w.db (1 table(s)) SELECT * FROM t; rc=1 --- clash db w.db (1 table(s)) SELECT * FROM t; cols a row zz ok 1 row(s) from 1 examined rc=0 --- huge ... row -1 rc=0 --- trail ... ok 0 row(s) from 0 examined rc=0 [recovery bypass] garbage store + base-0 log: 'wal store unreadable, rebuilt from the log' ...
- Mechanism: tools/tycho-db/store/store.ty: - decode (lines 284-290): the row loop reads nvals with take_u32 and appends whatever take_val returns. It never checks nvals == len(cols) or that each value's type matches cols[vi].ty. - decode also returns Ok (line 295) without checking off == len(b), so trailing bytes are ignored. - take_val (lines 243-248): get_u64 accumulates into a signed int, so a T_POS magnitude of 2^63 or more wraps negative, and a T_NEG magnitude of 0 or above 2^63 is accepted. - take_col (line 253): ignores tag bits above COL_KEY. - The short row then goes into _build_index (line 306, rows[r][c]), which panics at open for a KEY column, or into exec's SELECT projection, which panics ...
- Suggested fix: In decode, require nvals == ncols and each value's type == cols[vi].ty, refuse 'off != len(b)' at the end, and reject non-canonical magnitudes (T_POS >= 2^63, T_NEG of 0 or above 2^63), all as Corrupt. Consider a whole-file checksum in StoreHdr.
D77. open_db fails open on two unrecoverable states: a corrupt store beside a zero-length log is 'rebuilt' as an empty database and overwritten, and a missing store beside a checkpointed log opens with only the post-checkpoint suffix
low, verified; compilers: both.
- Where: tools/tycho-db/wal/wal.ty@open_db. In the Err(e) arm, the 'sc.base_lsn != 0' test cannot see a zero-length log, because scan() reports base 0 for one. store.open (store.ty) returns an empty Db for an absent path, so the LostPrefix check is never reached.
- Observed: --- control: garbage store, no log store is corrupt: y.db is not a tycho-db store rc=1 store=27B --- bug: garbage store + zero-length log 1 statement(s) failed db x.db (0 table(s)) wal store unreadable, rebuilt from the log SELECT * FROM t; ERR no such table: t rc=1 store=21B log=17B TYDB1 (identical for tychoc-built and tychoc1-built tycho-db; original repro (a): 'before: store 40 bytes ... log ...
- Mechanism: tools/tycho-db/wal/wal.ty@open_db, the Err(e) arm of 'match store.open(store_path)' (lines 277-285). It refuses only when 'not sc.present' or 'sc.base_lsn != 0'. wal.ty@scan (lines 157-158) returns Scan(present=true, base_lsn=0, end=0) for a zero-length log, so a log with no header passes as one that 'reaches back to lsn 0', and 'rebuilt = true' produces an empty Db. main.ty@run_script then calls wal.checkpoint, and store.flush overwrites the corrupt store. Fix: 'if not sc.present or sc.end == 0: return Err(Storage(e))'. sc.end is 0 only for an absent or zero-length log, the same test open_db already uses at line 292 to re-base the log. Part (b) comes from store.ty@open, which returns an ...
- Suggested fix: In the Err arm, refuse when 'sc.present and sc.end == 0' (Storage(e) or LostPrefix). When the store path is absent but sc.base_lsn > 0, return LostPrefix(store_path, sc.base_lsn) instead of an empty Db.
D78. One idle TCP connection pins the serialised server forever: there is no read timeout, so every other client hangs
low, verified; compilers: both.
- Where: tools/tycho-db/srv/srv.ty@serve_conn / recv_line (net.read with no net.set_read_timeout_ms) and @serve_forever (one session at a time).
- Observed: Original repro (tychoc1 build): baseline: b'err no such table: nosuch\n.\nok bye\n.\n' while one idle peer is connected: b'<no answer after 30s>' 30s after the idle peer leaves: b'err no such table: nosuch\n.\nok bye\n.\n' Minimised repro (min/idle.py: one peer connects and sends nothing; a second peer sends "QUIT\n" and waits 5 s): == tdb0 (tychoc) alone : b'ok bye\n.\n' idle peer : b'<hung 5s>' ...
- Mechanism: tools/tycho-db/srv/srv.ty@accept_one / @serve_conn: the accepted fd is never given a deadline with net.set_read_timeout_ms. So @recv_line's 'net.read(fd, READ_CHUNK)' makes a blocking recv(2), whose default per corelib/net/net_shim.c@netx_set_read_timeout is "block forever". @serve_forever is strictly accept -> serve_conn to completion -> checkpoint -> accept, so while that read is blocked no other connection is ever accepted; later clients sit in the listen backlog indefinitely. There is no SO_KEEPALIVE either, so a half-open peer is never detected. A secondary gap: @recv_line's catch-all 'Err(e): return Err(Peer("read failed"))' would report a future net.Timeout as a transport failure ...
- Suggested fix: Arm net.set_read_timeout_ms(conn, N) after accept, map the timeout to Peer("idle") and end the session. While here, send an in-band 'err' block before closing on TooLong: today the peer just gets a reset (p54_maxline).
D79. The index path turns a cross-type comparison into an empty answer where the scan path reports an error, and WHERE columns are checked only against examined rows
low, verified; compilers: both.
- Where: tools/tycho-db/plan/plan.ty@_eq_key picks IndexEq without comparing the literal's type to the column's. store.probe then orders int before text (val_cmp) and finds nothing. exec._select never checks the predicate against 'cols' before _open.
- Observed: SELECT * FROM k WHERE id = 'x'; cols id ok 0 row(s) from 0 examined SELECT * FROM n WHERE id = 'x'; ERR cannot compare int with text (identical under the tdb0 build (tychoc) and the tdb1 build (tychoc1); EXPLAIN shows "access: index k.id = 'ada'" / "residual: -") Refutation of the nosuch half (scan path, no index): SELECT * FROM n WHERE id = 7 AND nosuch = 1; ok 0 row(s) from 1 examined SELECT * ...
- Mechanism: tools/tycho-db/plan/plan.ty@_eq_key (lines 142-159) turns 'col = <any literal>' into an IndexEq whenever col is indexed. It never compares the literal's type with the column's, and build() has only 'indexed: [string]', not the column types. tools/tycho-db/exec/exec.ty@_open (IndexEq arm, lines 137-140) converts the key with _lit and calls store.probe directly, skipping exec._cmp, which is the one place that raises Uncomparable. tools/tycho-db/store/store.ty@probe and tools/tycho-db/store/store.ty@_lower search with val_cmp, a total order that puts every int before every text. The probe therefore finds nothing and returns an empty set rather than an error. The IndexEq conjunct is removed ...
- Suggested fix: Before planning, in both _select and _explain, check the predicate against the catalogue: every Col must exist, and both sides of every Cmp must have the same type. As an alternative, _eq_key could require the literal's type to match the column's. Extend run.sh's differential with a text literal against an INT KEY.
D80. tychoc1: a string/bytes accumulator declared inside a match arm is never compiled to in-place append, so the loop takes O(n²) memory. json.stringify of a 20,000-element array uses 2.1 GB, and a 94 KB POST kills a tychoc1-built JSON server
critical, verified; compilers: tychoc1.
- Where: compiler/emit/emit.ty@_acc_scan (~line 7560): it recurses into IfS/WhileS/ForIn/For3 only, and MatchS (and SelectS) fall to '_: pass'. A local declared in a match arm therefore never becomes an accumulator, and 'out = out + x' is emitted as 'h_out = tycho_str_concat3(&_scope, h_out, ...)' on every ...
- Observed: min ('match o: Some(n): out := ""; for i < n: out = out + "0123456789"', n=30000): tychoc 'exit=0 out=300000 maxrss=10224KB'; tychoc1 'exit=0 out=300000 maxrss=4447232KB'. Emitted C: tychoc1 'h_out = tycho_str_concat(&_scope, h_out, TYCHO_LIT("0123456789"));' against tychoc 'tycho_str_append(&_scope, &h_out, &_len_h_out, &_cap_h_out, TYCHO_LIT("0123456789"));'. The original repros re-run: p11 arm ...
- Mechanism: compiler/emit/emit.ty@_acc_scan (lines 7560-7616) recurses only into IfS, WhileS, ForIn and For3; ast.MatchS and ast.SelectS (and ForRange/ParallelS) fall to '_: pass'. There are two consequences. (1) A self-append inside an arm never reaches 'selfapp', so _fnbody (line 7785) never puts the local in cs.accs. The Assign path (line ~5767) then emits tycho_str_concat per iteration, which is O(n^2) memory. (2) A non-append assignment inside an arm never reaches 'other', so an accumulator appended elsewhere stays in cs.accs. The ordinary Assign path (line ~5890, 'h_s = rc') rebinds the pointer without resetting _len_/_cap_. The next tycho_str_append uses stale len/cap on the new buffer, which ...
- Suggested fix: Recurse into MatchS arms and SelectS arms in _acc_scan, as src/tychoc.c's accumulator analysis does. Add a fixture with a 20k-iteration arm accumulator under an RSS ceiling, and run json.stringify on a large array in the tychoc1 corelib lane.
D81. tychoc1: a recv() used as an expression is built in the function's own _scope, so it is used after free when it escapes the function (SIGSEGV or a corrupted Option), and it grows memory without limit inside a loop
high, verified; compilers: tychoc1.
- Where: compiler/emit/emit.ty@_recv_expr (lines ~4860-4874): the destination arena of tycho_chan_recv_N and the arena_alloc of the Some cell are the hard-coded string "&_scope" instead of cs.arena. Callers affected: _orret under a return (commit da3f0fa9 fixed this for calls but not for recv), a ...
- Observed: min/main.ty: tychoc '2 alpha beta' exit 0; tychoc1 'Segmentation fault' exit 139 (under -fsanitize=address,undefined: 'runtime error: applying non-zero offset 18446744073709551608 to null pointer' in tycho_str_concat5, TYCHO_SLEN of a garbage string, then SIGSEGV; ASan does not report a UAF because arena blocks are pooled). min/bind: tychoc '2 alpha beta' exit 0; tychoc1 SIGSEGV exit 139. ...
- Mechanism: The bug is in compiler/emit/emit.ty@_recv_expr (lines 4860-4874). The builder hard-codes the string "&_scope" in two places: the destination arena passed to 'tycho_chan_recv_N'/'tycho_chan_tryrecv_N', and the 'arena_alloc' of the Some cell. It should use 'cs.arena'. Under a 'return', cs.arena is '_parent', and '_retval' (line 3174) / '_orret' (line 3223, which since da3f0fa9 evaluates its operand with '_ex' in the current arena) treat the node as already homed in '_parent'. The value is therefore returned uncopied, from the arena that the return just freed. In the bind-then-return form, the local-ownership check near lines 5617/5679/5690 trusts the same arena and skips the copy. Inside a ...
- Suggested fix: Use cs.arena in both places in _recv_expr, as _orret now does for its operand. Add recv cases (direct return, or_else, bind-then-return, Option return) to tests/or_else_escape.ty, plus a loop-RSS check in the tychoc1 lane.
D82. Overwriting a heap value in a long-lived map never reclaims the old value, so tycho-kvsrv grows about 1 KB of RSS per PUT to the same key without limit (both compilers)
medium, verified; compilers: both.
- Where: Codegen for 'm[k] = v' in both compilers: tychoc emits '(*tycho_mapc1_slotptr(&_scope, &(h_m), k)) = <new value built in the owner arena>', and tychoc1 does the same with tycho_mapM1_slotptr. The evicted value is abandoned in the map's arena. By contrast, runtime/tycho_rt.c@tycho_arr_str_set ...
- Observed: map/c0 100000 exit=0 out=1 maxrss=10240KB map/c0 1000000 exit=0 out=1 maxrss=79748KB map/c0 3000000 exit=0 out=1 maxrss=236164KB map/c1 100000 exit=0 out=1 maxrss=10360KB map/c1 1000000 exit=0 out=1 maxrss=79744KB map/c1 3000000 exit=0 out=1 maxrss=236160KB arr_control/c0 100000 maxrss=10344KB arr_control/c0 1000000 maxrss=10356KB arr_control/c0 3000000 maxrss=10348KB arr_control/c1 1000000 ...
- Mechanism: tychoc: src/tychoc.c@gen_stmt case S_INDEXSET, the is_map non-compound branch (around lines 12713-12720). It builds 'v' in the map's owner arena (plus copy_into for a place RHS) and emits '(*<map>_slotptr(owner,&m,k)) = v;'. It never reads the slot's previous heap payload and never calls arena_recycle on it. The runtime's generated <map>_slotptr (for example tycho_map_si_slotptr) returns &m->evals[e] for an existing key. There is no map counterpart of runtime/tycho_rt.c@tycho_arr_str_set, which does the copy-then-recycle guarded by arena_owns (MM-9). tychoc1: compiler/emit/emit.ty@_lvalue, the ast.Index/_is_map branch (around line 4441), emits the same bare '(*_slotptr(...))' store with no ...
- Suggested fix: Give map value stores the MM-9 treatment. Before writing a heap value into an existing live slot, recycle the old payload when arena_owns(map_arena, old), copying first in case v aliases old, as tycho_arr_str_set does. Extend it to struct/array values through their size. Pin it with an RSS-bounded overwrite loop and a kvsrv same-key PUT check.
- Current disposition (FRICTION #193): fixed in both emitters. Map replacement and deletion release the old nested owned graph through generated typed recyclers; the 96-MiB two-compiler regression covers strings, arrays, structs, enum payloads and captured closures.
tests/recv_loop_scale.shseparately verifies 100,000 compositerecvpayloads remain bounded, closing the actor-loop temporary addendum alongside D81's arena fix.
D83. tychoc1 rejects constructing a generic enum's variant through a package qualifier ('box.Has(41)', 'box.Nothing$(int)') with internal, location-less messages. tychoc compiles and runs both
medium, verified; compilers: tychoc1.
- Where: compiler/emit/emit.ty@_call (~line 3700): '_qvariant(&cs, _qpfx(&cs, q), n)' does not find variants of a generic enum, so the call falls to '_call(... 'box__Has')' and dies with '_bad("unsupported call ...")' at line 4399. The explicit type-argument form hits '_bad("explicit type arguments -- Phase ...
- Observed: == has true tychoc: unsupported call 'box__H' with 1 argument(s) tychoc1 exit=1 == typearg true tychoc: explicit type arguments -- Phase 8 tychoc1 exit=1 (The original repro gave the same: p31 tychoc '42' / tychoc1 'tychoc: unsupported call 'box__Has' with 1 argument(s)' exit=1; p39 tychoc 'true' / tychoc1 'tychoc: explicit type arguments -- Phase 8' exit=1. './tychoc1 --typecheck' on each input ...
- Mechanism: The bug is in compiler/emit/emit.ty@_call, lines 3700-3704, together with @_qvariant (line 3126) and @_gen_variant (line 3967). For 'q.n(...)' with q a package, _call first tries '_qvariant(&cs, _qpfx(&cs, q), n)'. _qvariant deliberately skips generic templates and instances ('if _is_gen(...) or _is_inst(...): continue'), so it returns -1. _call then recurses as '_call(&cs, l, "", _qpfx(&cs, q) + n, tas, args)', which turns the name into the mangled 'box__H'. In the recursive call, both '_bare_variant' and '_gen_variant' match against the bare variant name ('cs.ge[gi].variants[j].name == n'), so 'box__H' never matches. - With explicit type args, the call reaches 'if len(tas) > 0: ...
- Suggested fix: Resolve qualified generic variants in _call by instantiating the enum from the argument types or the explicit type args (the same path the unqualified local case uses). Add tests/pkg/generic_enum_variant_cross covering both forms.
D84. tychoc1 builds per-iteration values in the function arena where tychoc uses the loop scratch or recycles: '[string]' element overwrite (no MM-9) and variadic packs grow without limit in a loop
medium, verified; compilers: tychoc1.
- Where: (a) Element store: tychoc1 emits '(*tycho_arr_str_ptr(&(h_xs), i)) = tycho_str_concat(&_scope, ..., tycho_int_to_str(&_scope, h_i))', and tychoc emits 'tycho_arr_str_set(&_scope, &(h_xs), i, tycho_str_concat(&_scr8, ...))'. The MM-9 recycle and the loop scratch are both missing. (b) ...
- Observed: peakrss prints "<stdout> / <maxrss KB> <ms>": arr tychoc: 10 1664 44 | arr tychoc1: 10 64128 76 va tychoc: 4500082500000 1664 61 | va tychoc1: 4500082500000 189184 165 window_naive (documented MM-9 bench): tychoc 15777800 3836 242 | tychoc1 15777800 47868 108 Scaling of the arr/va loops under tychoc1: 250k iters 17280KB, 1M 64128KB, 4M 251776KB. tychoc stays ~10.2 MB at every N under rss.py. ...
- Mechanism: (a) compiler/emit/emit.ty@_stmt, ast.PlaceSet arm (~line 5920). For a non-SOA Index target it sets cs.arena = _ownerof(target) and emits 'lc = rc2', where lc is '(*tycho_arr_str_ptr(&(xs), i))' from _lvalue (emit.ty (line 4456 at b9df4aa)). It never calls tycho_arr_str_set, so the MM-9 recycle of the evicted element in runtime/tycho_rt.c@tycho_arr_str_set is skipped. The whole RHS, including temporaries like int_to_str, is also built in the owner arena (&_scope) instead of the loop scratch. tychoc's equivalent is src/tychoc.c S_INDEXSET (~12739-12745): for T_ARRAY_STRING/IS_ARRC it emits 'tycho_arr_%s_set(owner, &arr, ix, v)' with v evaluated in the current scope/scratch. (b) ...
- Suggested fix: Emit tycho_arr_str_set (and the struct/array equivalents tychoc uses) for element stores, with temporaries in cs.arena. In _pack, use cs.arena for the buffer and plain _ex for the elements. Add both loops to an RSS-bounded tychoc1 lane.
D85. core:sqlite passes the database path and exec SQL to C as NUL-terminated char* with no interior-NUL guard: open() opens a different file and exec() runs a truncated statement, both returning Ok
medium, verified; compilers: both.
- Where: corelib/sqlite/sqlite.ty@open (sqlite3_open(path, ...)) and @exec (sqlite3_exec(d.h, sql, ...)); also @_run_stmt, where passing len(sql) to sqlite3_prepare_v2 does not stop SQLite at the first NUL
- Observed: Original repro (tychoc): p15: column index out of range / rows before: 5 / exec(DELETE ... NUL ... WHERE id = 5) -> Ok(5) / rows after: 0 p14: len(path) = 132 / io.write_bytes refused the NUL path / tables: 1 / real.db exists: true Minimized (identical under tychoc and tychoc1): open(real.db NUL junk) -> Ok seed ok exec(DELETE NUL WHERE id=3) -> Ok(3) rows left 0 reseed ok exec_params(DELETE NUL ...
- Mechanism: corelib/sqlite/sqlite.ty checks nothing before its string arguments reach C. - @open: passes 'path' straight to 'sqlite3_open(const char*)', which opens the prefix before the first NUL. - @exec: passes 'sql' to 'sqlite3_exec', which takes no length and runs the prefix. - @_run_stmt: passes 'len(sql)' to 'sqlite3_prepare_v2', but SQLite's tokenizer still stops at the first 0x00, so the truncated prefix is compiled and run. This reaches exec_params, query, query_params, query_null and query_null_params whenever no bound parameter lost its placeholder to the cut. All three return Ok. Fix: a has_nul guard (as in corelib/io/io.ty@has_nul) at the top of open, exec and _run_stmt, returning Err. ...
- Suggested fix: Add a _has_nul guard to open, exec, and _run_stmt (returning Err("... interior NUL ...")), mirroring io._has_nul. Pin it with a corelib/test/sqlite case asserting that the DELETE-with-NUL is refused and that the rows remain.
D86. The documented 'Some(1) == None' / 'opt == None' is rejected by both compilers. tychoc1's rejection is a location-less emit-phase die, and tychoc's names the mangled type ('util__Id')
low, verified; compilers: both.
- Where: tychoc: the comparison typecheck ("cannot compare %s with None"). tychoc1: the checker accepts the expression and compiler/emit/emit.ty@_ex (ast.NoneE/OkE/ErrE arms, ~line 2440) dies through _bad() with no location. The same applies to 'r == Ok(...)' (p01_res_void_cmp).
- Observed: none_eq: tychoc -> 'min/none_eq/main.ty (line 3 at b9df4aa): error: cannot compare Option(int) with None' (rc=1). tychoc1 --typecheck -> 'TYPECHECK-OK files=1 sixb=0' (rc=0). tychoc1 -o -> 'tychoc: 'None' here has no Option type to belong to' (rc=1, no file, no line). none_lhs ('None != x'): tychoc -> 'main.ty (line 3 at b9df4aa): error: cannot compare None with Option(int)'. tychoc1 -> the same ...
- Mechanism: (a) In compiler/types/tcheck.ty, the 'ast.NoneE' arm (~l.1623) synthesizes 'opt(?)' when nothing expects an Option; 'OkE' and 'ErrE' similarly leave '?'. compiler/types/tcheck.ty@_binop (~l.1953) then hits 'if not known(lt) or not known(rt): return "bool"' and accepts the comparison without an error. Emit has no expected type (cs.want) either: compiler/emit/emit.ty@_ex (NoneE/OkE/ErrE arms, ~l.2441-2451) calls compiler/emit/emit.ty@_bad, which is 'die("tychoc: " + msg)' and has no line. src/tychoc.c@resolve_expr_inner does reject it at the 'lt != rt' comparison check (~l.7957), because bare None is typed T_NONE, whose type_name is "None". (b) src/tychoc.c@nominal_name returns the stored ...
- Suggested fix: Either ground the bare constructor from the other operand's type (making the doc true), or fix the docs and make both checkers reject with a located message suggesting 'is None'. Unmangle the type name in tychoc's message.
D87. tychoc1's builtin-collision warning tells a sub-package author that 'package main' has no prefix and to rename. The function is in 'package res' and 'res.close(...)' works; tychoc gives the right advice
low, verified; compilers: tychoc1.
- Where: compiler/types/resolve.ty@_shadow_warn: it branches on rs.prefix (the resolver's current prefix when the warning is emitted), not on the declaring package, unlike src/tychoc.c line 4638, which uses g_cur_pkg_prefix at the declaration
- Observed: tychoc: .../min/p/p.ty (line 3 at b9df4aa): warning: 'len' collides with the builtin of the same name -- every unqualified 'len(...)' calls the BUILTIN, so this procedure is unreachable by that name (§3.7). Rename it, or call it qualified as 'pkg.len(...)'. 3 | fn len(s: string) -> int: built .../min/m_tychoc 7 tychoc1: .../min/p/p.ty (line 3 at b9df4aa): warning: 'len' collides with the builtin ...
- Mechanism: compiler/types/resolve.ty@_shadow_warn (lines 372-389) branches on 'len(rs.prefix) > 0'. It is only reached from _addl during build_table (line 520, via the calls at lines 405/408), and build_table never assigns rs.prefix: the constructor at line 425 starts it as "", and it is first set per file in resolve() at line 1135, after build_table returns. So the library-wording branch is unreachable. src/tychoc.c@parse_fn (line 4637 at b9df4aa) tests g_cur_pkg_prefix, the prefix of the package being parsed, which is correct.
- Suggested fix: Pass the declaring pkg into _shadow_warn and test 'pkg != ""', not 'len(rs.prefix) > 0'. Add a tests/diag case with the collision in a sub-package.
D88. tycho-httpd answers '431 Request Header Fields Too Large' to a small head whose Content-Length exceeds the budget, where 413 applies. core:httpd's single TooLarge conflates the two
low, verified; compilers: both.
- Where: corelib/httpd/httpd.ty@read_request_resume (a framed size past cap returns Err(TooLarge)), @ReqErr (documented as 'no header terminator in sight -> 431'), and server/main.ty (Err(httpd.TooLarge) → refuse(..., 431, ...))
- Observed: Identical under ./tychoc and ./tychoc1: == corelib level (cap=64) body over cap: TooLarge head_complete=true head over cap: TooLarge head_complete=false reason_phrase(413) = Status == tycho-httpd on the wire small head, CL past budget -> HTTP/1.1 431 Request Header Fields Too Large | The request headers exceeded this server's limit. oversized head (no CRLFCRLF) -> HTTP/1.1 431 Request Header ...
- Mechanism: corelib/httpd/httpd.ty@read_request_resume: line 311-312 ('if need > cap: return (Err(TooLarge), buf)', where the head parsed and only the framed body is over budget) returns the same variant as the no-terminator fallback at line 342. corelib/httpd/httpd.ty@ReqErr line 56 documents TooLarge as the no-terminator case only. Consumers map the single variant to 431: - server/main.ty@serve_once line 526-527 ('Err(httpd.TooLarge): refuse(..., 431, error_response(431), ...)'), and its explain(431) text "The request headers exceeded this server's limit." - tools/tycho-kvsrv/main.ty@handle_connection line 122-123 ('text_response(431, "request too large")'). A caller can currently tell the two cases ...
- Suggested fix: Add a ReqErr variant (e.g. BodyTooLarge) for the framed-size case, or have callers inspect whether a head terminator exists. Map it to 413 in server/main.ty and tycho-kvsrv, and add 413 to reason_phrase (202/409/413/422/429/503 are also missing and print as 'Status').
D89. Wrong docs hit while building the app: the core:sqlite header usage example does not compile, and the corelib reference says io.append is 'read-rewrite, not atomic' although it is O_APPEND
low, verified; compilers: both.
- Where: corelib/sqlite/sqlite.ty (line 8 at b9df4aa) (header comment 'sqlite.exec(&db, "CREATE TABLE IF NOT EXISTS t(x INTEGER)") or_return'). docs/reference/corelib.md (line 441 at b9df4aa) ('append(p, s)' (read-rewrite, not atomic) and append_text '(the same ...)'), against corelib/io/io.ty@append ...
- Observed: tychoc and tychoc1 print the same error on min/header/main.ty: min/header/main.ty (line 6 at b9df4aa): error: 'or_return' here produces int, which this statement discards -- bind it (x := ... or_return) 6 | sqlite.exec(&db, "CREATE TABLE IF NOT EXISTS t(x INTEGER)") or_return rc=1 min/fixed ('_n := sqlite.exec(...) or_return'): both compilers print "built ...", the program prints 0, rc=0. strace ...
- Mechanism: Stale docs. corelib/sqlite/sqlite.ty (header comment, line 8) was not updated when exec's return type became Result(int, string) (sqlite.ty (line 73 at b9df4aa), the change-count delta from FRICTION #69), so the bare 'or_return' statement is rejected by the unused-or_return-value rule in both compilers. docs/reference/corelib.md (line 441 at b9df4aa) (core:io bullet) still describes the old read-rewrite append, although corelib/io/io.ty@append now calls append_text, which goes to corelib/io/io_shim.c@iox_append_str (fopen "ab" = O_WRONLY|O_CREAT|O_APPEND). No gate compiles .ty header comments; docs-fences only runs fenced blocks in .md files. So neither text could have gone red.
- Suggested fix: Fix the two texts. Run the sqlite header's usage block through docs-fences or corelib-doc-check so it cannot rot again.
D90. make debug-check pins gdb's wording: gdb 15.1 (Ubuntu 24.04) prints 'No line 999 in file', the gate requires 'No compiled code for line 999', so make ci is red on this host
medium, fixed 2026-09-27.
- Observed: make debug-check on Ubuntu 24.04 with gdb 15.1.
- Mechanism: tools/tycho-debug/run.sh leg [5] grepped one GDB message spelling. It now accepts both
No line 999 in file(GDB 15.1) andNo compiled code for line 999(GDB 17). - Verification:
make debug-checkpassed all seven legs on Fedora 44/GDB 17.2, including leg [4]'s Ctrl-C interrupt when idle. Ubuntu 24.04/GDB 15.1 is not available here; its reported wording is directly covered by the alternate matcher.
Negative results
Classes each lane probed and found nothing in, which is what says where the map is blank rather than unvisited (audit-brief.md §7).
- (runtime) str() of floats: probed subnormal (5e-324 -> 4.94065645841247e-324), -0.0, 1e308, 1.7976931348623157e308, 0.1+0.2, 9007199254740993.0, INT64_MIN-as-float, NaN/inf/-inf; every finite value matched the documented shortest-round-trip form and NaN/inf matched the documented implementation-defined C spellings. No wrong ...
- (runtime) str/int of INT64_MIN and INT64_MAX: -9223372036854775808 and 9223372036854775807 render correctly; itoa negation via unsigned magnitude is sound.
- (runtime) Sized-int and float conversions (to_u8/to_i8/to_u32/to_u64/to_i64/to_f32/to_int) with NaN, +/-inf, 1e39, out-of-range and negative inputs: all produced the defined wrap/0/clamp answers (to_u64(nan)=0, to_u64(-1.0)=2^64-1, to_f32(1e39)=inf, to_int(3.9)=3); no UB, ASan/UBSan clean.
- (runtime) strings.parse_int / parse_int_checked / parse_float: probed overflow, '9223372036854775808', '-9223372036854775809', leading/trailing space, '+', '0x', 'nan', 'inf', '-0', 400-digit strings, '1e99999', '1e-99999', '.5', '5.'; lax variants fail open as documented (SKILL/FRICTION #5), _checked variants return the ...
- (runtime) input()/read_all(): NUL bytes, no trailing newline, CRLF, ~3 MB binary with no newline, empty stdin, 200 KB line then 5 MB tail; byte lengths correct, interior NUL preserved, ASan clean.
- (runtime) substr / slice s[a:b] with INT64_MIN, INT64_MAX, negative, inverted bounds: clamps as documented (FRICTION #5, spec 30.3); no overflow in end-start, ASan clean.
- (runtime) Built-in SCALAR array reserve ([]int/[]float/[]string) with huge/negative N: tycho_cap_check aborts ('reserve capacity out of range') or a non-wrapping large request cleanly OOMs ('tycho: out of memory'); never wraps to a tiny buffer. This is the guarded control that the composite finding violates.
- (runtime) Fast map reserve ([]string:int, []int:int, string:float, int:float) and composite map reserve with huge N: allocations are dominated by the per-entry 'elive' byte array of size ec, which hits a clean huge-alloc OOM before any wrapped tiny allocation can be written; no heap overflow observed (unlike composite arrays).
- (runtime) Env knobs TYCHO_THREADS, TYCHO_MAX_TASKS, TYCHO_BLOCK with negative/zero/garbage/huge values: ncpu() returns the documented requested value (parallel-for still clamps chunks to 64, _pts[64] never overflowed), TYCHO_MAX_TASKS garbage falls back to 1024 via atol, TYCHO_BLOCK is capped at 2^40 (no shift UB) and a huge ...
- (runtime) channel capacity 0 and -1: clean abort 'tycho: channel capacity must be >= 1'; capacities 1 and 2 work correctly.
- (runtime) Deep recursion (rec(100000000)): the sigaltstack stack-overflow guard fires cleanly with 'tycho: stack overflow -- recursion too deep' (exit 1), no segfault.
- (runtime) tychoc vs tychoc1 output parity on a sweep of programs (INT64_MIN division-overflow abort, 1<<63 and shifts, float 0/0 and 1/0 specials, pow(-1,0.5), string reverse/pad_left/parse_int, map insertion-order iteration, int-map delete-half then sum, recursion, big multiplies): identical exit codes and stdout on every case ...
- (runtime) Map delete correctness: []int:int insert 50, delete every even key, sum remaining -> consistent between compilers and matches expected; backward-shift delete (Knuth) leaves lookups correct.
- (runtime) chr()/to_char() out of 0..255 (negative, >255): aborts 'chr(n) out of byte range 0..255'; str(char) of NUL and interior-NUL bytes preserved via length-headered strings.
- (runtime) Builtin emit coverage audit (char_at, substr, find, split, chr, to_char, is_null, to_ptr, write_file) across both compilers: all agree; pow is the unique builtin missing an emit case in the self-hosted compiler.
- (shims) Emitted-C quality (bad/UB C accepted): emitted C from 100+ example/test programs compiled under gcc AND clang with -Wall -Wextra -fwrapv; the only warnings are two identical runtime-boilerplate ones (unused param 'si' in tycho_fault_handler, a -Wmisleading-indentation in tycho_map_si_str) present in EVERY program, ...
- (shims) tychoc vs tychoc1 differential on arithmetic/UB edges: intovf (INT_MAX+1 wraps to INT_MIN, INT_MIN/-1 traps 'division overflow'), shifts (<<63/<<64, arithmetic >>), signed div/mod signs, float inf/nan/1e308, float->int out-of-range trap, typed-int narrowing (to_u8/i8/u32/i16 + sign-extend), string/bytes slice ...
- (shims) Compiler compile-time scaling: N sequential statements, N chained tiny functions, and N-element array literals (all-1s, cycling 0..99, and distinct large values) up to N=20000 all compile in ~13–22 ms — linear, no super-linear path. (A one-off 21.9 s reading was traced to concurrent load from a parallel ASan cc build, ...
- (shims) fd/memory leak on error paths: 10000 failing io.read_text/open_lines(on a dir)/read_bytes/exists + 5000 failing os.exec + 2000 os.run('true') left /proc/self/fd stable at 4 the whole time — no descriptor leak on any error path probed.
- (shims) core:path traversal: safe_join refuses ../, absolute, backslash, drive-prefix and //host cases and keeps literal '....' and percent-encoded '..%2f' as ordinary names; clean() resolves .. correctly incl. above-root and rooted cases. No lexical-fence escape found.
- (shims) core:image ceiling with 32-bit-wrapping dimensions: a 32768x32768 PNG (RGBA size = 2^32, PNG_IMAGE_SIZE wraps to 0) and a 40000x27000 PNG (wraps to 25 MB) both decode to Err(Corrupt) under ASan+UBSan — libpng's simplified reader rejects them at finish_read; no under-allocation/overflow. 30000x30000 (3.6 GB, no wrap) ...
- (shims) core:datetime out-of-range/overflow: to_unix/from_unix round-trip for years 10000, 0, -1, -4713, 999999999; parse_iso rejects 9999-13-40 and 8-digit years; the POSIX-TZ parser under ASan+UBSan with 20+-digit offsets, giant M-rule numbers, and secs=INT64_MAX/MIN produced no UBSan report and no crash (garbage offset for ...
- (shims) core:os argv hardening: exec with an interior-NUL argv element, an empty argv, and a 5001-element argv (> OSX_ARGV_MAX 4096) all return -1; no crash.
- (shims) core:crypto empty/oversized inputs: sha256(''), random_hex(0), key_random(0)->null, pbkdf2 dklen 0 and 2000 ->null, aead with a wrong-length key ->!err — all graceful, no crash/UB under ASan.
- (shims) core:regex other pathologies: catastrophic-backtracking shapes ((a|a|...)*b, deeply nested 200-level parens, ((a{50}){50}){41}=102500 over ceiling) are either refused at compile or return instantly — confirming the product ceiling and DFA behaviour for those; the DoS is specific to a pattern AT the ceiling matched ...
- (shims) core:compress multi-member & trailing-junk gzip: a two-member gzip decompresses only the first member (6 bytes, matching gzip -c 'trailing garbage ignored' rather than Python's concatenation) and a member followed by non-gzip junk returns Ok with the first member — lenient like gzip(1); noted as behaviour, not a ...
- (ffi) ABI signature diff, mechanical and complete: ffi_diff.py (scratch find/ffi/ffi_diff.py) extracted all 161 extern fn declarations in 23 files under corelib/, server/ and tools/ (server/ has none). It lowered each one with a model of spec §24.1, parsed the prototypes that tychoc and tychoc1 actually emit (clang JSON ...
- (ffi) The 13 sqlite3 int-vs-i64 mismatches are benign on x86-64 SysV at every reachable input below 2 GiB. Disassembly of /lib/x86_64-linux-gnu/libsqlite3.so.0 shows the int returns are zero-extended (mov %r12d,%eax; movzwl), and sqlite3_changes/total_changes tail into the *64 variants, so Tycho reads the untruncated count. ...
- (ffi) tools/lsp_shim.c and tools/tycho_shim.c use 'long' for tycho_int. That is ABI-identical on LP64 Linux, and the only affected input (tycho_read_n n) is bounded to 64 MiB by lsp.ty parse_content_length. Portability only (LLP64), not reproduced.
- (ffi) Interior-NUL guards on string params, checked for all 86 string/[string] params: io (all), net, os (system/run/exec/exec_out), http url/content_type, tls host, path.real, datetime.offset_at, and strings.parse_float (grammar scan) refuse. regex passes n and refuses a NUL pattern. pbkdf2 password and ct_equal pass ...
- (ffi) String returns, ownership and lifetime: every string-returning shim returns a static, __thread or handle-owned buffer that is copied at the call site before the owner is freed (http.get_body, os.run, sqlite errmsg/column_text, iox_read_line, dw_next with the stream still open). NULL maps to "" in both compilers for ...
- (ffi) Bytes passing: no shim writes into a const input buffer or reads past the given length (net/tls/udp write, io write/write_at/append, compress x4, image decode/encode with plen >= w*h*4 checked, http COPYPOSTFIELDS with POSTFIELDSIZE_LARGE). Returned (out, outlen) buffers are pre-initialised to 0/NULL at every emitted ...
- (ffi) Slices passed as char*: s[0:5] is a fresh NUL-terminated copy (tycho_str_substr + tycho_str_alloc), so a sliced string cannot expose its parent's tail to C. Checked in the emitted C.
- (ffi) Extern library-name shell injection ('extern "m;touch X;" fn ...') is refused by both tychoc and tychoc1 (cc_safe_name), and no file was created.
- (ffi) FFI declaration parity: 27 probes (inout u8/f32, returns of [string]/Option(int)/Option(H)/f32/bool; params fn/tuple/vector/newtype/enum/Channel/[3]int/[u8]/Option(string)/$T; sink/variadic/default; duplicate extern; extern vs fn; handle destructor a Tycho fn or 2-param). Accept/reject agrees between compilers in ...
- (ffi) Sized-int extern params: an int argument to an i32 param is refused by both compilers, and to_i32(-7) through abs() gives 7 in both. Handle destructor misspelled, a Tycho fn, or with the wrong arity is refused by both (FRICTION 126 holds).
- (ffi) net_shim netx_poll_readable / accept_wait / read: bounds on nfds, ms and max are present. tls read/write clamp to INT_MAX. image IMG_MAX_OUT and compress ZD_MAX_OUT hold in code review (no probe needed beyond reading the arithmetic).
- (net) Path traversal and decoding (22 request-line cases, p04): '../', '%2e%2e', '..%2f', '%252e' double-encoding, overlong '%c0%ae', '%00', raw CR in the target, bad or trailing '%', absolute-form, backslash and '/./' dot segments all gave 400, 403 or 404, never a file outside the root.
- (net) Symlink inside the www root pointing at /etc (p09): GET /escape/passwd gives 403.
- (net) Response splitting through the 301 Location (p04): the target is ctl-checked after decoding, and render_head strips CR/LF from every header. No injection; Location stayed '/sub/' and '/./sub/'.
- (net) Access-log forging (p09): ESC bytes in the method and target were written as '.'. Log line 'w1 127.0.0.1 G.[31mET /.[0m 405 647 0.082ms', 0 lines containing a raw ESC.
- (net) Header cap boundary (p09): a head of exactly 16384 bytes gets 200 and 16385 gets 431. Across 40 send-then-read attempts each at 16385 B and 20 KiB (p10), every client received the full 431 before the RST.
- (net) Quadratic header processing (p03): one header name of 1000/4000/8000/16000 bytes answered in 3.0/1.3/2.2/3.7 ms, so string concatenation is amortized and there is no super-linear cost at the server's cap.
- (net) fd exhaustion (p05, ASan build, RLIMIT_NOFILE soft 64): 200 simultaneous client connections, then released; the server stayed alive and served the next GET with 200. 0 sanitizer reports.
- (net) Client reset mid-response (p05, ASan build): 30 SO_LINGER(0) resets during a 4 MB body gave 30 'write-failed' log lines. The server survived with no SIGPIPE and 0 sanitizer reports.
- (net) Slow drip, one byte per 100 ms with no end (p09): 408 after 1.60 s against --first-ms 1500. The whole-head deadline holds and is not reset per byte.
- (net) Pipelining and a partial second request on keep-alive (p16, ASan build): the second pipelined request is dropped and a split request's tail is refused 400 ('- TP/1.1. 400'). This matches the documented 'PIPELINING IS NOT SUPPORTED', with no splice and 0 sanitizer reports.
- (net) Unsupported methods and preludes (p04): POST, lowercase 'get', 'OPTIONS *' and the HTTP/2 preface all gave 405; HTTP/0.9 'GET /' gave 400.
- (net) Range and conditional requests (p09): ordinary forms, suffix, '-0', reversed, past-EOF, uppercase unit and multipart gave 206/416/200 as the README documents. If-Modified-Since equal to Last-Modified gave 304 and a garbage date gave 200.
- (net) core:net argument validation (p06, ASan+UBSan): listen/connect with port -1, port 65536, 256.1.1.1, an empty host, ::1 (IPv4-only by design), a 5000-byte hostname and connect to port 0 all returned Err(Failed). read with max 0 or -5 gave Failed; a 1 ms timeout gave Timeout; a -7 ms timeout re-arms blocking; empty ...
- (net) Descriptors at or above FD_SETSIZE (p12, ASan+UBSan, 1100 filler sockets): accept_wait on listener fd 1103 and wait_readable on accepted fd 1105 both worked ('ready 1'). poll() is used throughout, with no select() limit.
- (net) Compiler parity for the network code: tychoc- and tychoc1-built tycho-httpd gave identical results on the framing cases (p01/run_t1.py), and every corelib fixture above printed identical output under both compilers.
- (db) Lexer/parser robustness: 18,000 statements under the ASan+UBSan build (truncations and byte/token mutations of demo.sql, token soup with NUL, 0x7f, 0xff, UTF-8, quotes, huge ints), 6 seeds; no sanitizer report and no crash (p55_sqlfuzz).
- (db) 40 hand-picked grammar edge cases: 1 MiB identifiers and text values, keywords as table/column names, '<>', '==', '=<', '! =', parentheses, multi-row VALUES, ';;', two statements on one line, 2^63-1, 2^64-1, 50-digit leading-zero literal, lowercase keywords, '_' names. All accepted or refused cleanly (p17_lex).
- (db) Integer range: 9223372036854775808 and 18446744073709551615 are refused with a lex error, never read as 0. INT64_MIN crafted into a store round-trips through a checkpoint unchanged (p17_lex, p64_hugecounts).
- (db) NUL bytes: a text value containing \0 sent over TCP and in a script file is stored, compared and returned by its true length ('a\0b' != 'a'). tycho_str_cmp and tycho_str_eq are length-based (p13_srv, p53_misc).
- (db) Silent wrong answers against sqlite3 (python sqlite3 3.45.1) as oracle: 1,200 random WHERE clauses (1-3 conjuncts, all six operators, col/lit, lit/col, col/col, lit/lit) over INT/TEXT columns with and without KEY, including non-ASCII, empty and prefix strings and 2^31/2^32/2^53/2^63-1 edges. 0 mismatches under the ...
- (db) Index vs scan row ORDER with duplicate keys, after reopen (index rebuilt at decode): 16 query pairs, identical order (p58_dupkeys).
- (db) WAL truncated at every offset (0..203) and every byte flipped with 0xff: always a clean BadLog, or a replay of exactly the whole records before the damage. No crash under ASan. The only silent change came from base_lsn bytes 9-16, reported as finding 7 (p09_waltrunc).
- (db) Store truncated at every offset (0..155): always 'file ends mid-record' or not-a-store. Never accepted short (p08_storefuzz).
- (db) Huge counts in a crafted store (ntables, ncols, nrows or string length = 0xffffffff): refused in 40-160 ms with no large allocation (p64_hugecounts).
- (db) Crash recovery via --crash-after over 120 randomized crash/append cycles, with torn-tail damage injected: recovered rows equal the model exactly every time (p57_multicrash).
- (db) Server kill -9 at random points while one client inserts and reads each ack, 30 cycles and 11,405 rows: no acknowledged row lost, no duplicates, no extras (p41_srvkill). The small store keeps the checkpoint window below hit rate; finding 1 hits it deterministically.
- (db) Server protocol fuzz under ASan/UBSan: 300 sessions of random binary, byte-mutated statements and pipelined bursts, with and without half-close. The daemon stayed up with no sanitizer report (p56_srvfuzz).
- (db) Pipelining 2,000 INSERTs in one write, CRLF, stray \r mid-line, a lone '.', empty lines, comments and lowercase quit are all answered in order (p13_srv).
- (db) SIGPIPE: net_shim.c sends with MSG_NOSIGNAL (and SO_NOSIGPIPE where available), so a peer that vanishes mid-response cannot kill the server.
- (db) Memory per SELECT (200 full scans of 100k rows in one session: RSS flat at 41 MB) and per empty session (200 connect/close: RSS flat at 50 MB): no leak (p62_selectmem, p63_sessleak).
- (db) Deep nesting: 1k and 10k AND-conjuncts and nested EXPLAINs work. At 100k (script mode only, since the server's 8 KiB line cap bounds it) the runtime prints 'tycho: stack overflow -- recursion too deep' and exits 1. That is the documented recursive-descent limit class, not reported (p14_depth).
- (db) tychoc vs tychoc1 functional parity on tycho-db: transcripts and store bytes are cmp-identical over 3 fuzz seeds, and the sqlite differential agrees under both builds.
- (db) Port range: --port=99999999999999999999 cannot reach bind with an out-of-range port, because net_shim resolve4 checks 0..65535 (p37_cli; the lax parse itself is noted below).
- (archives) tycho-ar x containment: 29 crafted archives ('', '.', 'a/..', '..', '../x', '/abs', 'a/../../x', '..\\x', 'C:x', 'a\\0b', '..\\0', 'x/../..\\0/y', 'a//b', '\\n', '...', dupes, file/dir conflicts, 50 MB bomb honest and lying, multi-member and zlib-wrapped payloads, mtime 1e18, '+1' length), each extracted under ...
- (archives) tycho-ar parse robustness: 400 random byte, splice and delete mutations of a real 3-member archive through 't' and 'x', built with ASan+UBSan: 0 crashes, 0 exit codes other than 0 or 1, 0 files outside dest
- (archives) core:zip reader memory safety: 19 hand-built archives (CD count 65535 on a 1-entry file, cd_off 0xFFFFFFFF, local offset past EOF, csize and usize lies, CRC mismatch, overlapping entries, nlen 65535, zip64 markers, empty, EOCD-only, 21-byte runt, 64 KB and NUL names) plus 600 structural mutations (u16/u32 fields set ...
- (archives) core:zip bomb: a 200 KB entry declaring 200 MB inflates as declared; a lying usize=1 is refused after inflating. Both are bounded by the documented 1 GiB ZD_MAX_OUT ceiling (by design)
- (archives) path.safe_join adversarial property: exhaustive over the alphabet {a . / .. \\ : NUL space ~}, up to 4 tokens, against 8 bases (55,176 pairs). The only non-contained results carry an interior NUL (for example '..\\0' becomes base/..\\0), and every corelib consumer refuses those first (io._has_nul guards all 25 path ...
- (archives) path.resolve_under control: a symlink whose target EXISTS outside the root (dir or file) is correctly refused; only the dangling case escapes (finding 1)
- (archives) core:json vs Python json: 29,000 mutated documents (brackets, escapes, \\u surrogates, raw 0x00/0xFF/0xC3, numbers, whitespace incl. 0x0B/0xA0) gave 0 value mismatches and 0 accept/reject mismatches, apart from the documented deliberate refusals of lone surrogates, 1e400 and 1e-400
- (archives) core:json raw UTF-8 validation: exhaustive over all 16.7M three-byte string bodies, 0 disagreements with a validity oracle; depth cap: 2000 nested accepted (also under ASan), 2001 gives TooDeep; million-digit numbers, 1e-1000000 forms and 20,000 300-digit integers all parse in under 0.2 s
- (archives) core:json output is identical across tychoc and tychoc1 builds on the 8000-document corpus; the same holds for csv, toml and the zip/ar harnesses
- (archives) core:csv vs Python csv.reader: 5000 random documents over {a b , \" \\n \\r \\r\\n space \"\"}: 0 mismatches; 400k doubled quotes parse in 0.03 s (linear)
- (archives) core:utf8: exhaustive over all 3-byte sequences (2,650,112 valid, same checksum as Python) and the 4-byte F0-FF x all-b1 x edge-b2/b3 grid (9216 valid, same checksum); encode/decode round-trip over every code point 0..0x110004: 0 mismatches
- (archives) core:url decode/decode_form vs urllib.parse.unquote_to_bytes: 20,000 random strings over {% 4 1 g G + a 0 F f %2 %25 space NUL 0xFF}: 0 mismatches
- (archives) core:markdown XSS: 19 variants (mixed-case and whitespace-prefixed javascript:, java<TAB>script:, : entity, attribute break-out via quotes in href, src and alt, data:, vbscript:, raw <script>, <img onerror>, nested links): every scheme refused or text escaped; no live attribute or tag emitted
- (archives) core:toml numeric and datetime edges (37 scalar values: INT64 bounds, 0x8000000000000000, 64-bit 0b, 1e400, 1e-400, 1e1_000, nan/inf, '3.', '.5', invalid dates and times, +24:00 offsets): every difference from tomllib is a documented fail-closed refusal or one of FRICTION #62's listed lenient cases; no wrong value
- (archives) ASan+UBSan sweep of the toml, csv, markdown, codec and json harnesses over every case above: 0 reports
- (archives) core:compress decompress on corrupt, truncated and zlib-wrapped input: fails closed or decodes; the growth loop and ZD_MAX_OUT ceiling behave; the (uInt) cap truncation at inputs of 1 GiB or more in the inflate paths ends in TooBig or Truncated, never an overflow
- (program) HTTP framing/parsing under adversarial raw sockets (37 cases: pipelining, CL/TE smuggling shapes, duplicate/negative/15-digit CL, TE chunked, NUL/CR in target, absolute-form, '*', LF-only, a header without a colon, HTTP/1.0, a 1.2MB header block, 2MB without a terminator, slow drip) against the app's ASan+UBSan builds ...
- (program) core:json RFC 8259 edges through the server: lone surrogate, invalid UTF-8, raw NUL refused with correct offsets. \u0000, -0, 1e308 and a 20-digit integer round-trip byte-exact (lexeme kept). Duplicate keys take the last value. Depth 20000 is refused as TooDeep. Depth 1999 parses fine on spawned worker threads (no ...
- (program) Persistence: SIGKILL under load, then restart with the OTHER compiler's build gives byte-identical /kv?limit=1000 and /tasks?limit=500 listings. Torn-tail detection plus truncation works across three restarts. The 16 pending tasks were requeued after a crash and all completed.
- (program) Concurrency: 16 keep-alive clients × 300 mixed PUT/GET/DELETE/POST (4800 requests) gave 0 errors on both builds, p99 3.6-6ms. SIGTERM via core:signal shuts down cleanly. The ASan+UBSan+LSan builds of the full app (both compilers, including sqlite and select-default) exit 0 with no reports.
- (program) Parity harness over the app's own packages (api.route over 14 requests, store log/replay/compaction, jobs, generic ring buffer): tychoc and tychoc1 output byte-identical.
- (program) Mixed-feature parity probes (p17/p23/p30), all identical on both compilers: a generic struct Cache($K,$V) with 'where hashable(K)' and an inout generic method; a recursive enum across packages; tuple returns; newtype map keys and arithmetic; closures in arrays and composed closures; parallel for over [struct] and over ...
- (program) Recv forms that are correct on tychoc1: 'match recv(ch): Some(r): return r', 'x := recv(ch) or_return' in an Option fn. select-arm bindings returned from a function are correct. A select recv in a 2M-message loop keeps flat memory (10MB) on both compilers.
- (program) or_else/or_return returning Option/Result payloads from ordinary calls (p05) is correct on both compilers (the da3f0fa9 fix holds for calls). A variadic pack returned through a call (p19) is correct.
- (program) core:cli edge cases (valued flag eats a following '--flag', '--help=1', repeated options last-wins, positionals) behave as the cli.ty rule table documents.
- (program) core:sqlite parameter binding, BEGIN/COMMIT batches from the actor, and ORDER BY/LIMIT queries work. exec on a closed Db SIGSEGVs in sqlite3_total_changes(NULL) (p12), but FRICTION ('A Db handle is an ordinary struct over a ptr ... nothing in the language stops a use-after-close. Not a finding') documents it, so it is ...
- (program) core:log: the header example 'log.init(log.Info, false)' compiles on both compilers (consts do cross packages, despite an internal comment in log.ty saying they do not).
- (program) Compiler diagnostics hit by my own mistakes were accurate and named the fix: reserved 'handle'/'in', or_else in a void fn, or_else yielding the unwrapped type, an unused import, an unused local, and discarded Results.
- (cli) Shell injection via library NAMES (extern "Lib", --link, --pkg, deps pkg-config names): blocked identically on both by cc_safe_name/cc_safe — 'extern "m; touch PWN"' and '--link 'm;touch X'' are rejected with 'illegal character in ... name', no file created.
- (cli) Shell injection via source path / package directory metacharacters (space, ', ", $(...), backticks, ;, \, newline, %s%n): every path on the cc line is shell-quoted (shq in tychoc, _shq in tychoc1); builds succeed and run 'hi' with no command execution. Additionally a metachar-named directory can never be a valid Tycho ...
- (cli) TYCHO_CORELIB pointing at a plain file, a nonexistent dir, a value with shell metacharacters, or empty: both fail closed (used only as a filesystem path, never shelled; -I<root> is shq'd on the cc line); empty env falls back to the beside-binary corelib on both. Only the wording differs (tychoc 'cannot open package ...
- (cli) -o equal to the source path, -o to a directory, -o in a missing directory, -o -, -o /dev/null: consistent verdicts on both (source-path -o overwrites the source and builds on both; directory/missing-dir fail on both; -o - writes -.c and cc rejects it on both; /dev/null builds on both).
- (cli) No-clobber guard: a hand-written .c at the intermediate path is refused by both ('already exists and was not written by tychoc'); --emit-c is exempt on both; a tychoc-written .c (runtime banner) is fair game on both.
- (cli) NUL byte in source: tychoc rejects ('contains a NUL byte -- a source file must be text'); documented behaviour. BOM at start: both reject identically ('unexpected character'). Source with no trailing newline: both build and run correctly.
- (cli) Import graph edge cases: self-import (import "."), a two-package cycle a->b->a, importer of a cyclic package: both detect and refuse with the identical 'import cycle through package ...' message and exit 1.
- (cli) Leading-dash source names, with and without '--' end-of-flags: both handle correctly (e.g. '-o out -- -lead.ty' and relative '-lead.ty' after '--') and build.
- (cli) Reporting flags on malformed input: --print-shims/--print-deps/--bundle on lex errors and resolve errors behave consistently across both compilers (only --symbols diverges — reported as a finding).
- (cli) Repeated -o (last wins) and multiple source arguments (last wins): consistent on both.
- (cli) --cc pointing at a nonexistent compiler: both fail rc=1 ('/no/such/cc: not found', 'C compilation failed').
- (cli) Concurrent builds to the SAME -o (6x parallel): both race on the shared intermediate .c and some invocations fail with exit 1; no crash, no ASan/UBSan report, and no corruption of unrelated files — inherent to a fixed intermediate path and identical on both.
- (cli) 50 MB source file: both accept and emit C without crashing (tychoc 0.08s, tychoc1 1.77s — a perf gap in the opposite direction, not a defect).
- (cli) --version and --help/-h: exit 0 on both; usage on missing source exits 1 on both.
- (cli) Deeply nested but valid expression (2000-deep '1+1+...'): compiles on both; the documented recursive-descent stack overflow only appears at thousands of nested brackets (fuzz/README known limitation).
- (cli) tychoc built with ASan+UBSan (-fsanitize=address,undefined -fno-sanitize-recover) on a trivial program: clean, no UBSan reports for ordinary CLI paths.
- (scale) 1 MB identifier ('aaaa... := 3', 2 MB file): both compilers linear, 0.02s/15MB (tychoc) and 0.04s/42MB (tychoc1), and the C builds.
- (scale) 10 MB string literal: tychoc1 linear (1.25/2.5/5/10 MB -> 0.06/0.09/0.15/0.30s, 30->222MB). cc takes 2.2s and the binary prints 10000000. tychoc rejects above 4095 bytes with 'string too long' as spec §3.9 requires (parity difference noted in notes).
- (scale) Integer match with 1000-8000 arms: both 0.02-0.04s, emitted as a flat switch.
- (scale) Enum with 1000-8000 variants and an exhaustive match: builds and runs correctly (cc -O3 13.7s / 5.6s at 8000). Mildly quadratic only at 16k-32k variants (see notes).
- (scale) Nested match statements 20/60/120 deep (indentation-bounded): both compile in 0.03s, cc OK, output correct.
- (scale) Nested 'for' / conditional 'for' / 'if' up to 250 levels: no crash in either compiler. tychoc stops at 64 loop levels with an unlocated 'tychoc: block nesting too deep' (notes). tychoc1's memory growth there is the same root cause as finding 3.
- (scale) Package import chain 10/63/64/65/100 deep: both agree (63 OK, 64+ 'tychoc: package nesting too deep'). 250-2000 sibling packages: tychoc1 linear (0.33s at 2000). tychoc's 256-import truncation is in notes.
- (scale) Deeply nested TYPES [[...int]], Option(Option(...)), nested tuples to 300: no crash. Both reject cleanly past their caps (tychoc 256, tychoc1 512); tuple expressions to 500 in tychoc1: 1.43s, no crash.
- (scale) Nested Some(Some(...)) 30 deep and nested array literals [[[...1]]] 30 deep: linear in both (0.02s).
- (scale) Plain (non-generic, non-method) nested calls mx(1, mx(2, ...)) 22 deep: both 0.02s. The exponential cost needs method syntax or a generic callee (finding 2).
- (scale) Generic recursion at a growing type w_i([x]) 5-40 levels: both 0.02s, no runaway.
- (scale) 1e6-element array literal front end: both linear (tychoc 0.51s/296MB, tychoc1 0.67s/435MB). Only cc is slow (finding 8).
- (scale) 4000 functions each calling the previous (funcs_chain): both at or under 0.22s.
- (scale) 50,000 functions all called (C output 10 MB): cc -O3 time linear (3125/6250/12500 fns -> 4.9/8.0/13.2s).
- (scale) Lambdas in separate functions (8000) and inline lambdas with no named locals (8000): linear in both (<=76MB).
- (scale) Parameter count: both agree on 'too many parameters (max 16)' at 64/65/250/1000/4000 params.
- (scale) f-string with 500 holes: both compile. Nested-brace/raw-string limits were not in this lane.
- (scale) Runtime of every compiled probe that built was checked: output matched the expected value in every case (no miscompile found in this lane).
- (parity) Numeric literal lexing (43 programs): decimal, hex, binary, leading-zero '017', '_' separators, '0o', '0x', '0b2', '1.', '1.e5', '1e', hex floats, '.5', 1e400, 1e-400, 2^63 and 2^64 in decimal, hex and binary, and u32/u64/u8/i8/f32 destination ranges. tychoc and tychoc1 agree on accept/reject, diagnostic and output in ...
- (parity) String and char escapes (50 programs): \n \t \r \\ \" in strings, \x \0 \u \U \' octal \a \e and a trailing backslash rejected in strings; the full char escape set, \xNN with 1 or 3 digits or non-hex; raw control bytes, DEL, UTF-8 bytes, raw pieces with newline, CR or quotes, and adjacent-piece joins that would splice ...
- (parity) f-string basics (40 programs): nested string literals holding braces or escaped quotes, nested f-strings, '{{'/'}}', empty or unclosed holes, lone '}', 20 holes, holes of type bool/float/u32/array/map/bounded/tuple, comments inside a hole. All SAME; the only defects found are the ones in the f-string finding.
- (parity) Shifts: 9 integer types × << and >> × 6 values × 13 counts (0 to 200), plus negative counts. All SAME, and they follow §30.1 (count ≥ width gives 0) and §30.2 (negative count aborts).
- (parity) Unary -, ~ and 'not', wraparound for + and *, and -(MIN) on all 8 sized ints and f32: 68 programs, all SAME.
- (parity) int '/' and '%' with negative operands, int MIN / -1 (clean abort), int MIN % -1 (0), and const-folded versions: SAME in both. The defect is limited to sized ints and element-wise arrays in tychoc1.
- (parity) Scalar match (30 programs): negative literals, negative and cross-zero ranges, ranges ending at int MIN or MAX, a 0..MAX range with 4 more arms (switch codegen), char ranges including escapes and \x80-\xff, bool, int consts and const ranges, overlap and reversed-range refusals, 100 dense and 55 sparse arms. All SAME ...
- (parity) Generics and 'where' (35 programs): numeric/comparable/has_str/hashable/defaultable instantiated at u8, f32, char, bool, struct, array, map, fn and a newtype; explicit 'f$(T)'; const-generic '[$N]T'. All SAME diagnostics. Polymorphic recursion at a growing linear type is refused cleanly by the 1024-instance cap in ...
- (parity) Closures and lambdas (29 programs): by-value capture of u8, f32, vector, fixed, bounded, packed, tuple, map and struct; loop-variable capture; nested and escaping closures; generic higher-order calls; lambda literal adaptation to u8/f32/tuple returns. All SAME.
- (parity) Option/Result nesting, or_return (including nested Option and a heap error payload), or_else returning bool/string/u8/f32/i8/tuple, 'is' rules and diagnostics: SAME, apart from the tuple-adaptation finding.
- (parity) Channels, select and spawn (30 programs): default and closed arms, capacity 0 or negative, send after close, double close, double wait, a receive listed twice in one select. SAME clean aborts or diagnostics.
- (parity) bounded[N]T: index past the count, lane names and swizzle past the count, push when full, struct-field copies, bool element refusal, element-wise refusal. SAME. soa push/pop/index/slice/copy independence and inout field: SAME, apart from for-in.
- (parity) packed/align(N): size_of$, the little-endian bytes bridge round trip, from_bytes length check both ways, align(16)/align(3)/packed+align refusals, arrays, soa and str of packed structs. SAME; only inout on a packed field is unsound.
- (parity) str() of every printable type (floats including subnormals, 1e21, 5e-324, -0.0; f32; all sized ints; char \x00/\xff; arrays, maps, structs, enums, Option, Result, tuple, bytes, bounded, fixed, vector): SAME. NaN printing as '-nan' is documented as implementation-defined (appendix F).
- (parity) Maps: insertion order after delete and reinsert, int-key order, missing-key reads for scalar and string values, m.get, map ==, nested place writes, tuple/enum/newtype keys, u8/float key refusal. SAME.
- (parity) String ordering operators over an 8×8 matrix (empty, case, tab, '~', embedded newline), strings with an interior NUL or high bytes, char ordering above 127: SAME and byte-wise.
- (parity) Const folding: overflow wraps, div0, MIN/-1 and negative-shift refusals, 1<<64, string folds up to 100 kB. SAME.
- (parity) Builtin edges: substr and string/bytes slices at ±2^63 (clamp, as documented), array slices out of range (abort), char_at/chr/to_char range aborts, split with an empty separator, find, reserve with a huge count, pop on empty, to_int at the int64 edges, exit/die. SAME.
- (parity) Compile-time scaling in both compilers, apart from the elif finding: + chains, string concat chains (clean 'too deeply nested' refusal at 2000), 1000-hole f-strings, 16000 locals, 8000 functions, 8000 enum variants, 16000-element array literals, 1000 joined literals, 1000-field structs, 250-deep nested ifs. All linear ...
- (parity) FRICTION #132 siblings: negative float and int literals in vector/fixed/bounded/dynamic array literals, vector assignment, return, argument, struct constructor, push, Option, element-wise operand, ==, math.clamp operands, struct i16 constructor, match arms (literal and const), tuple, variadic and or_else. No parity ...
- (codegen) C keywords as locals, params, functions, struct names and fields (auto register volatile restrict inline typedef signed unsigned goto extern static union sizeof double long short char void _Bool _Atomic): p07 compiled and ran under both compilers; every user name gets an h_/f_/S_/E_ prefix. Only enum payload-variant ...
- (codegen) libc and runtime macro names (EOF NULL linux stdin errno assert) as consts, newtypes, struct names, fields and lambda params: p08 ran correctly in both compilers.
- (codegen) Generic-function instance vs user fn collision ('id' at int vs a user 'id__int', and a user 'id__int_2'): p03/p04 printed the right values in both compilers (tychoc de-duplicates to '_2').
- (codegen) Named function-value thunk vs a user fn 'sq__clo': clo ran correctly in both compilers (the thunk is an unprefixed static and the user fn is h_sq__clo).
- (codegen) Sized-int arithmetic differential: 8 types × 14 edge values (0, ±1, ±2, 3, 7, ±100, 200, MIN, MAX, MIN+1, MAX-1) × (+ - * / % & | ^ << >> over counts 0..65, ~, <, to_int, to_float). 16k-line programs sz_*. tychoc and tychoc1 agree on every non-trapping line; the only diff is i64 MIN / -1 (part of finding 2).
- (codegen) Signed overflow, INT64_MIN % -1, -INT64_MIN, char ± int wrap, math.abs(INT64_MIN): u01 under ASan+UBSan at -O0/-O1/-O3 for both compilers gave identical output and no reports. abs(MIN) is documented in corelib/math/math.ty.
- (codegen) to_int(float) NaN/inf/1e300 gives a clean 'tycho: float-to-int conversion out of range' in both compilers, as specified.
- (codegen) Literal handling: out-of-range literal adaptation (u8 300, u32 -1, [u8] [256], f32 1e300, i8 200) is refused identically. Leading-zero literals (017, 010 in u32/f32/float/u64/u8/const/match arm/[010]int/broadcast) are emitted as decimal in both (oct). Float literal edges (1e-400, 5e-324, 1.7976931348623157e308) agree.
- (codegen) Evaluation order and value semantics: compound assign 'xs[f()] op= g()' calls f once; 'm[k] += 1' and 2-D 'grid[f()][g()] += 3'; and/or/not short-circuit with side effects (m04). Nested struct/array/map/fixed-array copy-then-mutate, tuple copies and arrays of arrays (m03). for-in over a collection mutated in the body ...
- (codegen) inout exclusivity: 'f(&xs, xs)', 'f(&o.ins[0].vs, o)', 'f(&m[k], m[k])' and 'two(&xs[0], &xs[1])' are all refused at compile time in both compilers (m01-m03). The hole is the assignment place (finding 1).
- (codegen) Strings with interior NUL: ==, <, map keys, struct/enum/tuple/array/bytes/Option equality and find are all byte-exact in both compilers (m05).
- (codegen) Struct-keyed maps with padding (bool+int+string fields) keep unique keys in both compilers (hk_bool, hk_string, hk_float).
- (codegen) Format-string injection: println/eprint/die of "%s%s%n%d" print literally in both compilers (fmt).
- (codegen) match over int ranges, negative ranges, const arms, char ranges and high bytes: mt gives correct and identical results.
- (codegen) Element-wise sized arithmetic ([u8] + - *, broadcast 250 + [u8], [i8] overflow, [3]u16 * 2): ew wraps identically in both compilers.
- (codegen) Builtin parity for args, char_at, chr, to_char, clock, floor, fabs, getenv, pop, reserve, split, substr, find, now, ncpu, keys, list_dir, zero$, sqrt, and str/f-string of float/f32 edge values including -0.0, NaN and 1e21: b01/bi identical. The only gap is pow, see notes.
- (codegen) Explicit type-argument calls (empty$(int)(), ident$(string)(..), count$(int)()) work in both compilers (ta).
- (codegen) -Wall -Wextra -O2 over the emitted C of all 295 tests/*.ty from both compilers (590 builds): only -Wmisleading-indentation (3706), -Wunused-label (4), -Wmissing-braces (3) and one -Woverflow on i32 '1000000*1000000', which is defined under the mandatory -fwrapv. No -Wsequence-point, -Wmaybe-uninitialized, ...
- (codegen) Arena growth when a statement-scoped call result is discarded in a 3M-iteration loop: peak live is 64 B under both compilers (mem01, TYCHO_ARENA_STATS).
- (codegen) Memoization through inout map ('memo[n] = fib(&memo, n-1) + fib(&memo, n-2)') is correct under gcc and clang for both compilers (f_memo). This rhs-first shape happens to be sequenced safely.
What the verifiers added
Each verifier re-ran its finding and often found more: a second defect beside the first, a cause the finder had wrong, or a part of the claim that did not hold. Those are listed here by finding. "secondary defect" rows are defects in their own right and are not counted in the totals above.
D02 (addenda). tychoc heap-use-after-free: a StructDef*/Variant* pointer is held across resolve_expr while a nested generic ...
- *secondary defect:* Separately from the use-after-free, release tychoc grew past 5.5 GB and was OOM-killed at enum nesting depths 30 and 40. Disposition: the repeated nested-constructor subtree work was removed in FRICTION #200; its 32-deep generic enum/struct regression runs under both compilers' 1.2-GiB cap.
D03 (addenda). tychoc1 takes cubic memory and time on an elif chain: 800 arms (30 KB source) need 5.3 GB, 1250 arms are ...
- *secondary defect — closed in FRICTION #138/#139:* Both compilers emitted quadratic-size C for an elif chain because each arm was indented one level deeper; native lowering is now iterative and both emit a flat
else ifchain. - *secondary defect — closed in FRICTION #202:* At 125 nested
forloops, native codegen refused at its 64-entry unwind stack while tychoc1 accepted. Native loop/unwind stacks now match the parser's 256-level ceiling;tests/nested_blocks_scale.shcompiles and runs 125 nested loops under both compilers. - *triaged resource limit:* Directly nested blocks still cost more than flat code (the baseline 250-loop case used 768 MiB in tychoc1), but nesting is bounded by the parser's 256-level limit. This is retained as a documented deep-input cost, not claimed as constant-space compilation.
D11 (addenda). tychoc read_file() aborts (heap buffer overflow) on any non-seekable source path (pipe, FIFO, process ...
- *secondary defect:* When a file's stat reports st_size 0 but the file has content, as with /proc files, tychoc silently compiles it as an empty program. tychoc1 reads the real content.
- *secondary defect — closed after the baseline:* Native source loading now reads until EOF rather than trusting file size;
tests/stream_source.shverifies that both compilers reject the nonempty, NUL-delimited/proc/self/cmdlineinput rather than compiling an empty program. - *correction:* The verifier said the heap-overflow claim is overstated on the shipped Linux build: no bytes are written out of bounds because tychoc aborts before the read.
- *correction:* The verifier lowered the severity from high to medium because the trigger is the user's own choice of input path, a pipe or FIFO.
- *correction:* tychoc1's unaffected reader is in corelib/io/io_shim.c, not io.c as the report said.
D12 (addenda). tychoc1 -g emits an un-escaped source path in #line directives and DWARF, corrupting debug paths (backslash ...
- *secondary defect:* Beyond the report, directory names such as a\xg and a\u make a tychoc1 -g build fail outright with a C compile error. tychoc builds and runs both.
- *secondary defect — closed in FRICTION #144:* Neither compiler escaped a newline in the source path. Both emitters now escape line breaks and other control bytes;
tests/debug_path.shcovers newline paths under both compilers and gcc/clang.
D13 (addenda). Exponential compile time and memory on nested calls. tychoc1: method-call chains (generic or not), nested ...
- *secondary defect:* tychoc1 rejects a UFCS call whose receiver is a constructor call, reporting that the struct has no field of that name. tychoc compiles the same code and it runs.
- *secondary defect:* The comment on emit.ty@_argtypes says re-emitting is cheap. That is wrong once calls are nested.
- *correction:* The verifier lowered the severity from high to medium, because the problem only affects compile time and has no runtime or security impact.
- *correction:* The report's scope was corrected. UFCS does not reach imported package functions, so corelib can only be hit through nested calls, not through method chains.
- *correction:* The report missed a root cause: the tychoc1 types pass (the CallVal arm of tcheck.ty@_ex2) also adds to the exponential cost.
D14 (addenda). An elif chain is lowered to one nested C block per arm: tychoc SIGSEGVs at ~7000 arms, clang (a supported cc) ...
- *secondary defect:* The tychoc resolver is a second recursion with no depth guard. It crashes at 100,000 arms, so fixing only codegen would not be enough.
- *secondary defect:* The SIGSEGV, which comes after partial C has already been written, contradicts both the spec's fail-closed nesting bound and README's claim that the fuzzer proves the compiler fails closed.
- *correction:* The verifier found that tychoc1's memory use grows cubically. The report had only recorded the result as a plain 'Killed'.
D15 (addenda). tychoc1 program_in copies the whole struct-definition text once per field: 900 structs x 20 fields (405 KB) ...
- *secondary defect:* In a separate, unrelated issue, tychoc has a hard cap of about 960 structs and reports 'too many structs'.
D31 (addenda). tychoc front-end (parse+resolve) is quadratic O(N^2) in top-level procedure count; ~15x slower than tychoc1 ...
- *secondary defect:* warn_at is a separate quadratic. It rescans the source from the start for every warning, so its cost is warnings times source size.
- *secondary defect:* The warn_at quadratic stays slow even with the sig_find hash patch, and tychoc1 has the same problem.
- *secondary defects — closed after the baseline:* Native and self-hosted diagnostic pipelines now index source lines once per file; the 6,000-warning registry stress test passes with both compilers under the memory cap.
- *refuted:* The claim that tychoc1 is near-linear does not hold. tychoc1 is also super-linear, just with a smaller constant.
- *correction:* The quadratic cost is not limited to parse+resolve. Codegen's gen_call also calls sig_find.
- *correction:* consts_find, variant_find and generic_find, which the report blamed, cost nothing on this fixture because their tables are empty.
- *correction:* The verifier lowered the severity from medium to low, because the slowdown only matters beyond about 10k top-level procedures.
D32 (addenda). --symbols disagrees on a name-resolution error: tychoc exits 1, tychoc1 exits 0 and still prints the index
- *secondary defect:* The disagreement is wider than name resolution. On type errors, unused locals and unknown procedures, tychoc also exits 1 while tychoc1 exits 0.
- *secondary defect:* When the input has a type error, tychoc1 --symbols also prints a wrong index row for the ill-typed expression.
- *secondary defect:* The tools check tests the LSP only against ./tychoc, so nothing tests the behaviour of the shipped binary.
- *secondary defect:* The driver.ty comment says emit_symbols follows tychoc's emit_symbols, but it does not apply the same error gate.
- *secondary defect:* Because of this behaviour, the tools/lsp.ty comment about falling back when a compile does not resolve is untrue for the shipped binary.
- *secondary defects — closed after the baseline:* The self-hosted
--symbolspath now checks types before printing;tests/cli_flags.shconfirms name and type errors exit nonzero without any partial symbol index under either compiler.
D33 (addenda). -o with an empty string: tychoc errors, tychoc1 silently ignores it and builds a default-named binary ...
- *secondary defect:* On the --emit-c path, tychoc1 with -o '' writes the C to stdout and exits 0, as if -o had not been given.
- *secondary defect:* tychoc with --emit-c -o '' also fails open. It writes a hidden .c file into the current directory, prints "wrote .c" and exits 0.
- *secondary defect — closed after the baseline:* Both compilers now reject empty
-obefore build or--emit-c;tests/cli_flags.shchecks both paths and asserts that no.cfile is left behind. - *correction:* The verifier said the finding's framing is wrong. tychoc is not the correct reference behaviour, because neither compiler checks for an empty -o.
- *correction:* tychoc's failure on the build path comes from cc by accident, not from its own validation, and the stray .c file is left behind.
D34 (addenda). Trailing bare -L/-I (no value): tychoc fails, tychoc1 swallows -lm and builds (fail-open)
- *secondary defect:* A second failure mode: a program that needs libm fails with a misleading undefined-reference error, and tychoc1 does not show the cc line that would explain it.
- *correction:* tychoc has the same missing check for a bare -L/-I. It escapes only because of where the flag lands on the cc line, so the fix belongs in both compilers.
D39 (addenda). reserve(xs, n) on a composite-element array with attacker-controlled n has no size_t-overflow guard: ...
- *secondary defect:* A possible adjacent defect the verifier did not verify: the map reserve doubling loops have no upper bound on cap.
- *secondary defect — closed after the baseline:*
tycho_map_cap_checkrejects negative and out-of-range map capacities before thecap*2/power-of-two loops; native and self-hosted generated composite maps and runtime map families are exercised bytests/audit_runtime_guards.sh. - *correction:* The trigger is element size, not whether the type is composite. Any element type of 2 bytes or more that uses the emitted per-type reserve can wrap, including u32.
D40 (addenda). Shipping self-hosted compiler tychoc1 cannot compile the documented builtin pow(x, y); tychoc accepts it ...
- *secondary defect:* When emit fails, tychoc1 leaves a truncated NAME.c behind that holds only the runtime prelude.
- *secondary defect:* For a package-local sqrt/pow in a non-main package, both compilers warn that unqualified calls go to the builtin, but they actually resolve to the package function, so the warning is wrong.
- *secondary defect:* tychoc1's copy of that shadowing warning names 'package main' even when it fires inside 'package lib'.
- *secondary defect — closed after the baseline:* both compilers now resolve a package-local builtin-named procedure before the builtin within that package, and the warning accurately distinguishes local calls from external qualified calls.
tests/package_builtin_shadow.shchecks the behavior, diagnostic and output under both compilers. - *correction:* The verifier lowered the severity to medium. The failure is a loud compile-time refusal, not a miscompile, and it affects one builtin.
- *correction:* The fix should also add pow to emit.ty@_shadowable, which lists sqrt and fabs but not pow.
D41 (addenda). channel(T, cap) with a large capacity overflows the cell-array size computation in tycho_chan_new -> tiny ...
- *secondary defect:* A second failure mode: for cap > 2^62 the signed doubling loop overflows (UB) to 0, never ends, and the binary hangs.
- *secondary defect:* The _WIN32 branch of ty_aligned_alloc64 has the same size wrap.
- *correction:* The finder's arithmetic was off. sizeof(HCell) is 128, not 64, so the wrap threshold differs, but the conclusion still holds.
- *correction:* The verifier rated it medium rather than high, because the out-of-bounds writes are linear with near-fixed values.
- *secondary defects — closed after the baseline:*
tycho_chan_newnow rejects capacities that exceed the cell-array size and checks each doubling before it can overflow;tests/audit_runtime_guards.shexercisesINT64_MAXunder both compilers. The Windows allocator-padding addition is unreachable for this caller: the accepted cell count is a power of two bounded bySIZE_MAX / sizeof(HCell), withsizeof(HCell) == 128; Windows execution remains unverified.
D42 (addenda). core:regex — a pattern UNDER the repetition-product ceiling still causes a 40+ second match-time DoS; the ...
- *secondary defect:* Common everyday patterns with no bounded repetition also cause match-time DoS.
- *secondary defect:* The audit-2026-08-16 doc claims POSIX ERE does not backtrack, that catastrophic shapes are harmless and that the search-box DoS is fixed. The verifier says this is untrue as written.
- *secondary defect:* The audit's own example of a safe pattern also blocks for seconds on a crafted subject.
- *correction:* The verifier found the repetition-product ceiling is not the cause; the unbounded pattern a+b is quadratic on its own.
- *correction:* Lowering the repetition ceiling, or capping subject length relative to state count, would not fix the DoS.
D43 (addenda). core:crypto — the interior-NUL sweep (FRICTION #75–#78, declared COMPLETE) missed the entire crypto hex ...
- *secondary defect:* The truncation allows signature forgery by extension, starting from a single legitimate signature.
- *secondary defect:* FRICTION #78 claims all string externs are length-carrying or NUL-guarded. That is false for the crypto hex-taking externs.
D44 (addenda). core:crypto hex inputs stop at the first NUL, so verify, HMAC and AEAD-decrypt accept input that was never ...
- *secondary defect:* docs/reference/corelib.md wrongly says chr(0) appends nothing and that a Tycho string cannot hold 0x00.
- *correction:* The AEAD case is milder: the extended ciphertext decrypts only to the authentic plaintext, so the defect there is that malformed input is not refused.
- *correction:* FRICTION 76 argued the MAC shape could not be reached. That argument does not carry over to verify or HMAC, where the attacker supplies the message.
- *secondary defects — closed after the baseline:* Public crypto hex-taking wrappers reject interior NULs before the shim's
strlendecoding;corelib/test/crypto/main.tycovers verify, HMAC, AEAD and key inputs. The corelib reference,SECURITY.mdand Base64 module comments now state that Tycho strings can contain NUL and distinguish that from C-string boundary truncation.
D45 (addenda). core:io — io.copy(src,dst) silently truncates dst to 0 bytes and returns Ok when src and dst are the same ...
- *secondary defect:* No symlink is needed. Copying a path onto the identical path string, or onto a ./ alias, also truncates the file to 0 bytes and returns Ok.
- *secondary defect:* The same bug reaches tychoc1: when -o aliases the --runtime source, the compiler destroys the runtime source file.
- *secondary defects — closed after the baseline:*
iox_copy_fileopens the source first and compares its device/inode with the destination before opening the destination, making same-file copies (including symlink aliases) a no-op.corelib/test/io/main.typins same-path preservation; the shared shim also protects the compiler's runtime-source copy.
D46 (addenda). core:sqlite open() and exec() act on the NUL-truncated prefix: open creates or opens a different database ...
- *secondary defect:* The prepare_v2 path also truncates at the first NUL, and FRICTION #70's len(sql) change has no effect.
- *secondary defect:* FRICTION #78's claim that all string externs are length-carrying or NUL-guarded is false for sqlite3_open and sqlite3_exec.
- *correction:* The Err the finder saw from exec_params came from an out-of-range bind index, not from a NUL guard. The fix must also guard _run_stmt.
- *correction:* The verifier rated the severity medium rather than high.
- *secondary defects — closed after the baseline:*
sqlite.open,sqlite.execand_run_stmtreject SQL/path strings containing NUL before calling SQLite.corelib/test/sqlite/main.tychecks NUL-bearing paths, direct SQL and prepared SQL.
D47 (addenda). core:http post/post_bytes pass a CR/LF in content_type into the outgoing request headers
- *secondary defect:* The bug goes beyond header injection. A CRLFCRLF in content_type ends the request head, so a whole second request goes out on the same connection (request splitting).
- *secondary defect:* A long content_type is cut short without any error. The 512-byte snprintf buffer means a 602-byte value goes out as a 497-byte header value.
- *secondary defects — closed after the baseline:* The HTTP shim rejects control bytes and refuses a header value that will not fit in the serialization buffer;
tests/http_header_guard.shproves both POST APIs reject CR/LF and 600-byte values under both compilers.
D48 (addenda). regex.group_start/group_end/group: a huge group index wraps the allocation size and rx_exec writes past a ...
- *secondary defect:* Even when the size does not wrap, a large group index makes rx_group allocate and fill (n+1)*8 bytes: about 1.6 GB and roughly 1 s per call for n=2e8, when the pattern has only 2 groups.
- *secondary defect:* Untested, reasoned hazard: on the 32-bit target, (size_t)n truncates the 64-bit index, but m[n] is then indexed with the full n.
- *correction:* The report said a plain (non-sanitized) build survives. The verifier found that is true only for patterns with few groups. With many groups, glibc's writes corrupt the heap and a later call crashes.
- *secondary defects — closed after the baseline:*
rx_groupchecksnagainst the compiled pattern's actual group count before allocation or indexing and bounds the match-vector multiplication.tests/audit_runtime_guards.shverifies an out-of-range group returns-1under both compilers; the samesize_tchecks cover 32-bit arithmetic, though no 32-bit run was available.
D49 (addenda). io.read_line/fold_lines and os.run/exec_out silently cut data at the first NUL, while io.read/read_lines keep ...
- *secondary defect:* SECURITY.md (line 112 at b9df4aa) is wrong for these call sites. It claims NUL truncation is documented at each call site and that binary-safe paths exist, and neither is true here.
- *secondary defect:* There is no bytes variant for capturing a subprocess's stdout, so a program cannot get output that contains NUL bytes at all.
- *secondary defect:* docs/reference/corelib.md's read_bytes sentence implies that io.read truncates at NUL, but io.read does not truncate.
- *correction:* The reporter cited the spec's 'does not inherit the exemption' clause. The verifier says that clause covers yes/no validators, not data readers, so the citation is overstated.
- *secondary defects — closed after the baseline:*
io.read_lineandfold_linescarry each line throughbytes/length-aware string conversion;os.runandexec_outreturn captured output with its byte length.corelib/test/io/main.tyandcorelib/test/os/main.tycheck child/file output containing NUL.SECURITY.mdnow distinguishes Tycho string storage from C-string boundaries.
D50 (addenda). core:compress — a valid gzip/deflate stream well under the documented 1 GiB ceiling is refused as TooBig; the ...
- *secondary defect:* The ceiling also fails the other way. Output above the ceiling is accepted when the first buffer (len*4+64) is already larger than the ceiling, because the check only runs when the buffer grows.
- *secondary defect:* At the default ceiling, a gzip input larger than about 256 MiB can inflate past 1 GiB without returning TooBig.
- *secondary defect:* ZD_TOOBIG and ZD_FAILED share the value 3, and _cause maps 3 to TooBig. ZErr.Failed can therefore never be returned, and init or allocation failures are reported as TooBig.
- *secondary defect:* The spec (§33.3) and the reference docs list only three ZErr causes and never mention TooBig or the ceiling.
- *correction:* The verifier lowered severity from the reported medium to low: the failure is fail-closed and has no memory-safety impact.
- *correction:* Clamping only the last growth step to ZD_MAX_OUT would not fix the bug. The limit has to be judged against the actual output size, because an output of exactly ZD_MAX_OUT can fill the buffer before the gzip trailer is read.
- *secondary defects — closed after the baseline:*
tests/compress_limits.shchecks the inclusive output ceiling and rejects the first byte over it;ZD_TOOBIGandZD_FAILEDhave distinct values. The spec and corelib reference now documentZErr.TooBigand the 1-GiB (or lower zlibuInt) output ceiling.
D51 (addenda). datetime.offset_at accepts out-of-range and overflowing POSIX TZ offsets (returns 8044431881044758032) where ...
- *secondary defect:* ty_tz_rule does not range-check the M rule fields. A huge week value costs about 0.85s of CPU per call in ty_rule_secs, and the shim gives a different answer from glibc.
- *correction:* The suggested fix (ty_tz_off returning NULL) would not give the expected answer of 0. It falls through to the setenv/tzset fallback, which on Linux clamps like glibc.
- *correction:* ty_tz_off is also used for the rule /time field. Capping hours at 24 there would reject valid rules, so that call needs its own bound, which must still prevent the overflow.
D53 (addenda). docs/reference/ffi.md still says u8/u16/i8/i16/i32/i64 are plain int to Tycho and that to_i32 returns int; ...
- *secondary defect:* docs/reference/builtins.md (line 121 at b9df4aa) repeats the wrong to_i32 return type. The original report missed this row.
- *secondary defect:* The spec line in docs/spec/14-ffi.md (line 126 at b9df4aa) is stale. It says core:path needs no guard, but path.ty@real calls an extern realpath and does its own NUL scan.
- *secondary defect:* In tychoc.c, ffi_sized_ctype and its call sites in parse_extern_fn are dead code. Their TK_IDENT guard can never be true because the lexer returns keyword tokens for the sized spellings.
- *correction:* The reference's claim is not wrong in every case. An int literal still coerces to a sized type, so only non-literal ints are rejected.
D54 (addenda). httpd.framing fails open on a repeated Transfer-Encoding whose last value is empty (and on an empty ...
- *secondary defect:* The backup framing check in server/main.ty@serve_once relies on the same header() != "" tests, so it has the same blind spot.
- *secondary defect:* The impact is worse for tools/tycho-kvsrv, which has no backup check. A smuggled DELETE inside a chunked PUT body deletes another key.
- *secondary defect:* The FRICTION entry saying a lone Transfer-Encoding is not ambiguous is stale documentation. A lone TE now gets a 400.
D55 (addenda). core:http post/post_bytes: CR/LF in the content_type argument injects arbitrary request headers onto the wire
- *secondary defect:* The fixed 512-byte header buffer in the shim silently truncates a long content_type and sends it with no error.
- *secondary defect:* The injection goes beyond adding a header: it can end the head early and send a complete second request, which is request splitting or smuggling.
- *secondary defects — closed after the baseline:* CR/LF and oversized values are refused before a request is sent, with both string and bytes POST APIs covered by the two-compiler
http_header_guardregression.
D56 (addenda). core:tls: the documented bounded-connect API (tls.connect_timeout, tls.set_timeout_ms) cannot be called from ...
- *secondary defect:* The same privatization sweep made sqlite.query_null and query_null_params private, although they are still documented as public.
- *secondary defect:* log.level_debug() is documented in the corelib reference but is now the package-private _level_debug.
- *secondary defect:* The TLS shim passes the timeout to poll with no upper bound on ms.
- *secondary defect:* The tls.ty header comment was not updated, and no test calls these functions from package main, which is why no gate caught the regression.
D57 (addenda). httpd.parse_request accepts header and request-line grammar that RFC 9112 requires rejecting: whitespace ...
- *secondary defect:* The comment on httpd's trim() says it folds header folding, but trim only strips whitespace. A continuation line is not joined to the line before it, so the comment is wrong.
- *correction:* Severity is limited: tycho-httpd turns keep-alive off whenever any Content-Length is present, so this server cannot be desynced across requests.
- *correction:* RFC 9112 only says SHOULD for rejecting the request line and version. These parts are defects because they break httpd's own documented contract, not because they break the RFC.
D58 (addenda). tycho-httpd Range: a position with leading zeros that is 16+ characters long is treated as 10^15, giving a ...
- *secondary defect:* There is a second failure mode: a last-pos written with leading zeros is clamped to end of file, so the server returns more bytes than were asked for.
- *secondary defect:* A zero-length suffix written as 16 zeros returns the whole file as a 206 instead of a 416.
- *correction:* The finding gave the wrong location. The server is at server/main.ty, not tools/tycho-httpd.
D59 (addenda). core:net shim truncates 64-bit fds to int after checking only 'fd < 0', so net.close_fd(fd + 2^32) closes an ...
- *secondary defect:* The scope is wider than close: accept and wait_readable also act on the aliased descriptor. accept returns a real connection, and wait_readable reports the alias value as ready.
- *secondary defect:* wait_readable's comment promises to fail closed on a bad fd, but the code only handles negative fds.
- *secondary defect:* netx_poll_readable narrows each fd to int for poll but returns the original un-narrowed value.
D60 (addenda). Cross-package "did you mean" suggests a package-private (_-prefixed) symbol, which the next compile then ...
- *secondary defect:* A private candidate can hide a usable public one: when the private name is declared first at the same edit distance, it is suggested instead of the public name that would compile.
- *secondary defect:* The same wrong suggestion also appears on the function-value path, not only on qualified calls.
- *secondary defect:* The self-hosted compiler tychoc1 has the same bug in a separate place, resolve.ty@_pkg_cands, which also has no filter for a leading underscore.
D61 (addenda). core:toml silently drops data from VALID documents: a parent table declared after its subtable, and subtables ...
- *secondary defect:* core:toml also fails open: it accepts several documents that a correct parser rejects, and silently loses data from them.
- *secondary defect:* With three levels of headers declared in reverse order, only the outermost table's data is kept.
- *secondary defect:* The duplicate-header check in parse only compares [t] headers with each other. It misses [t] mixed with [[t]], and headers that collide with dotted or scalar keys.
- *correction:* The fix must do more than merge valid documents. It must also reject the invalid ones, or they will still be accepted.
- *correction:* This is not a miscompile, as the report said. It is a logic bug in the pure-Tycho corelib library.
D62 (addenda). path.resolve_under accepts a DANGLING symlink inside the root, so a later write lands outside the root
- *secondary defect:* A dangling link used as a middle directory is also treated as absent and accepted. It only escapes if the target directory is created later.
- *correction:* Severity drops from high to medium: the only in-tree caller only reads files, and reading through a dangling link gives a 404, so the in-tree server cannot be exploited.
- *correction:* The impact is limited to creating new files outside the root. Existing files cannot be overwritten, because a link whose target exists is refused.
D63 (addenda). core:toml can be crashed and memory-exhausted by small inputs: no nesting cap, and quadratic time and memory ...
- *secondary defect:* The reference doc calls toml.parse fail-closed and says it returns Result. That is wrong, because a process that aborts cannot return Err.
- *secondary defect:* A tychoc1 build shows a second failure mode on deep nesting. Instead of hitting the stack guard, an 80 KB input dies with an out-of-memory abort.
D64 (addenda). zip.create silently wraps a name length >= 64 KiB and an entry count > 65535 (to_u16/to_u32), writing ...
- *secondary defect:* The documented claim that Python's zipfile reads the writer's output back byte-exactly is false for the minimized archive. Python raises BadZipFile on it.
- *refuted:* The part of the claim about sizes and offsets of 4 GiB or more was not demonstrated. The to_u32 wrap exists in the code but is effectively unreachable today.
D65 (addenda). core:compress shim truncates lengths >= 4 GiB to uInt: compress/raw_compress silently encode only len mod ...
- *secondary defect:* An input of exactly 4 GiB is a worse case. compress returns a valid empty gzip stream, and decompress returns Ok with 0 bytes.
- *secondary defect:* In zx_compress the (uInt)cap cast also truncates avail_out when deflateBound exceeds 4 GiB. This makes inputs just under 4 GiB fail spuriously.
- *refuted:* Only the deflate side is defective. The uInt casts on the inflate side (zx_decompress, zx_raw_inflate) fail closed.
- *correction:* The verifier recategorized the defect as silent data loss (an integrity bug) rather than a memory-safety bug.
- *correction:* The finder's Tycho-level OOM came from how the input was built, not from a hard host limit. The verifier reproduced the bug from a Tycho program.
- *correction:* The claimed zip.create consequence is secondary, because core:zip already writes to_u32 lengths and has no zip64.
- *correction:* The claimed tycho-ar impact is overstated. Members over 1 GiB could not round-trip even with a correct compress, because decompress hits ZD_MAX_OUT.
D66 (addenda). core:toml mis-parses quoted keys and quoted headers, and invents values from malformed literal strings and ...
- *secondary defect:* This is an extra case the verifier found: escapes inside a quoted key are never decoded.
- *secondary defect:* Because quoted headers keep their quotes, '[a]' followed by '["a"]' gets past the duplicate-header check.
- *secondary defect:* A source comment in _parse_array is misleading. It says its check works like the literal-string branch, but that branch is the weak one.
- *secondary defect:* The reference doc says the parser supports quoted keys, which the quoted-key mis-parsing contradicts.
- *refuted:* The '[a]]' case does not count as a defect. It is the same documented looseness about bare-key characters as 'a b = 1'.
D67 (addenda). core:json parse is quadratic in the number of members of one object (the duplicate-key scan)
- *secondary defect:* FRICTION.md is stale. It still says duplicate keys are all kept and that get answers with the first, but the code now keeps the last value.
D68 (addenda). markdown.inline is still O(n^2) when the terminator EXISTS: '['*n + ']' (the no_rb memo only caches absence)
- *secondary defect:* The source comment at markdown.ty (line 81-84 at b9df4aa) says the terminator search runs at most once per terminator. That is false when the terminator exists but no link forms.
- *secondary defect:* Two more input shapes are quadratic through the same rb path: a ']\(' tail with no ')' anywhere, and '!['*n+']' (about 3x slower per n).
- *correction:* The report's claim that ')' has the same problem, and its suggestion to also cache the backtick position, are wrong. Found ')' and backtick terminators are always consumed, so only ']' needs a cache.
- *correction:* The report said only tychoc is affected, but both compilers are, because the problem is in the corelib algorithm.
D69 (addenda). core:zip: every extract() re-reads the whole central directory and scans backwards over the whole file for ...
- *secondary defect:* The audit's timing for one EOCD scan is out of date. One 64 MB scan now takes 1.3-1.45 s, not 0.26 s, because the scan builds from_bytes$(Eocd) over a 22-byte slice at every offset.
- *refuted:* The trailing-junk acceptance, and failing open where Python refuses, is a documented decision in the audit, so that part of the report is not a new defect.
- *refuted:* The spec does not require rejecting trailing junk, so the report's call to refuse it like Python does is only a suggestion.
- *correction:* The verifier rated the severity medium leaning low: it is a performance-only DoS with no memory-safety impact, and no shipped consumer calls extract.
D70 (addenda). compress.decompress returns only the FIRST gzip member and accepts trailing garbage, as Ok
- *secondary defect:* zx_raw_inflate has the same flaw: it ignores avail_in after Z_STREAM_END. Its only caller, core:zip, passes an exact csize slice, so that caller is not affected.
- *correction:* The trailing-garbage half of the claim is weaker than stated: gzip(1) decodes such input and only warns (exit 2), and it silently ignores trailing zero padding. A fix may want to tolerate all-zero padding.
D71 (addenda). The checkpoint rewrites the store in place, so a crash, kill -9 or full disk during it destroys every ...
- *secondary defect:* serve_forever checkpoints after every session, including empty connections, which widens the window for data loss.
- *secondary defect:* STATUS.md (line 71 at b9df4aa) and CONTRIBUTING.md claim tycho-db recovers from a real kill -9, which this defect contradicts.
- *correction:* The data loss does not need a mutation. A read-only CLI run also checkpoints and can destroy the store.
- *correction:* The suggested rename-based fix needs a new core:io rename primitive, because none exists anywhere in corelib or the runtime.
- *correction:* Skipping the checkpoint when no record was appended would only narrow the window, not close it.
D72 (addenda). tychoc1 drops the in-place append for 'bytes' accumulators and for accumulators declared in a match arm ...
- *secondary defect:* A shape the report did not name is also quadratic under tychoc1: a string accumulator declared at function top level and appended only inside match arms.
- *secondary defect:* _acc_scan does not recurse into SelectS arms either, so select arms have the same accumulator gap as match arms.
- *secondary defect:* tests/accum_in_match.ty checks output only, so it does not catch the tychoc1 memory regression.
D73 (addenda). KEY index maintenance is quadratic in time and memory: 689 KB of INSERTs over TCP grows the server to 1.6 GB, ...
- *secondary defect:* The source comment at store.ty (line 39-40 at b9df4aa) says the opposite of the real behaviour: it claims index entries never move once placed.
- *correction:* The verifier found that wall time is not a reliable signal on this host because the per-INSERT WAL cost dominates it. The defect shows up in memory, not in time.
- *correction:* A control run with an int key stayed flat. This places the growth on the deep copy of the boxed enum Val during each shift, not on the shift itself.
- *correction:* The compiler and runtime follow the documented cost model, so this is not a compiler bug. The fix belongs in the tool's algorithm in store.ty.
D74 (addenda). A mutation whose WAL append fails is still applied in memory: the client gets 'err cannot write', yet the row ...
- *secondary defect:* Because a statement reported as ERR is still committed, a client that retries it ends up with a duplicate row.
- *secondary defect:* The existing NotWritten test gate misses this defect because it runs only one statement and never checks what happens afterwards.
D75 (addenda). Nothing locks the store: two processes on one database silently lose acknowledged commits (300 of 300 in one ...
- *secondary defect:* A second failure mode: in the racy case, two processes' WAL frames are written at the same offset, because each process tracks its own log end.
- *secondary defect:* The local CLI path accepts a store that a --serve process is already serving, even though source comments assume the server owns the file.
- *correction:* The fix cannot be made in tycho-db alone, because core:io has no lock primitive. A lock would first have to be added to core:io and then taken in open_db.
D76 (addenda). store.decode trusts row arity, value types and trailing bytes: a one-byte corruption panics the process ...
- *secondary defect:* The panic bypasses wal.open_db's rebuild-from-log recovery path. That path would otherwise handle a store that does not decode.
- *secondary defect:* In --serve mode, one client's plain SELECT on the corrupted table kills the daemon for all clients.
- *secondary defect:* take_val accepts non-canonical magnitudes: a T_POS value of 2^63 or more wraps to a negative number, and a T_NEG magnitude of 0 or above 2^63 is accepted.
- *secondary defect:* take_col ignores column tag bits above COL_KEY.
- *secondary defect:* Realistic torn-tail corruption, zeroing the last n bytes of the store, was never refused as Corrupt: it produced 38 panics and 92 silent accepts.
- *refuted:* The verifier does not count the offset-70 byte flip that silently changes a row as a decode bug. It produces a structurally valid value that only a checksum could catch, so it is a design gap.
D77 (addenda). open_db fails open on two unrecoverable states: a corrupt store beside a zero-length log is 'rebuilt' as an ...
- *refuted:* Part (b), a missing store beside a checkpointed log opening with only the post-checkpoint suffix, was not confirmed as a defect. No crash can produce that state, and run.sh documents it as accepted behaviour.
- *correction:* The suggested fix of raising LostPrefix on a missing store would change documented behaviour, and replay already fails safely when the suffix refers to tables created before the checkpoint.
- *correction:* Severity is low rather than medium: part (a) needs two faults at once, and a single crash on Linux cannot produce both.
- *correction:* The verifier found and tested a specific fix for part (a): also refuse when sc.end == 0, the same test open_db already uses to re-base the log.
D78 (addenda). One idle TCP connection pins the serialised server forever: there is no read timeout, so every other client ...
- *secondary defect:* Even without an attacker, a half-open TCP connection permanently wedges the daemon, because the server does not set SO_KEEPALIVE either.
- *secondary defect:* recv_line's catch-all error arm would report a net.Timeout as a transport failure ("read failed"), not as an idle drop. A fix that only adds a timeout would therefore report the wrong cause.
- *correction:* Adding a read timeout does not fully fix the monopoly. Under the serialised design, a client that keeps sending can still hold the server, so the timeout only covers the silent or half-open case.
D79 (addenda). The index path turns a cross-type comparison into an empty answer where the scan path reports an error, and ...
- *secondary defect:* The run.sh [3b] index-vs-scan differential gate probes only int keys, so it cannot detect this cross-type divergence.
- *refuted:* The claim that WHERE columns are checked only against examined rows was refuted, along with the report's third pair. The scan path behaves the same way, so this is not specific to the index.
- *refuted:* The conjunct-reordering divergence falls under the lazy, order-dependent semantics that the scan path also has, so it is not a separate index defect.
- *correction:* Severity is low: no wrong rows are ever returned. The only effect is an empty answer where a diagnostic should be.
D80 (addenda). tychoc1: a string/bytes accumulator declared inside a match arm is never compiled to in-place append, so the ...
- *secondary defect:* The same root cause also causes a memory-safety miscompile. An accumulator reset inside a match arm keeps stale _len/_cap sidecars, and the next append writes past the new buffer.
- *secondary defect:* The stale-sidecar miscompile gives wrong output under tychoc1, and ASan reports a global-buffer-overflow write.
- *secondary defect:* _acc_scan also skips select arms, ForRange and ParallelS bodies, not just match arms.
- *correction:* The verifier raised the severity to critical because of the memory-safety miscompile. The reported performance symptom alone would be high.
D81 (addenda). tychoc1: a recv() used as an expression is built in the function's own _scope, so it is used after free when ...
- *secondary defect:* try_recv goes through the same faulty _recv_expr builder, so it is affected too, not just recv.
- *secondary defect:* The in-tree tycho-kvsrv tool uses both affected forms, the return form and the in-loop form, so a tychoc1 build of it is exposed.
- *secondary defect:* The regression test added by the earlier fix da3f0fa9 has no recv case, which leaves this path untested.
- *correction:* The verifier rated severity high, not critical, because it found no path for untrusted input to trigger the fault.
D82 (addenda). Overwriting a heap value in a long-lived map never reclaims the old value, so tycho-kvsrv grows about 1 KB of ...
- *secondary defect:* Under tychoc1 there is a second, separate leak: the kvsrv store actor never reclaims the per-request temporaries from recv(cmd). Disposition: the D81 receive-arena fix plus loop scratch bounds it;
tests/recv_loop_scale.shruns 100,000 2-KiB composite receives under 96 MiB with both compilers. - *secondary defect:* tychoc1, the compiler that ships, does not implement MM-9 recycling for [string] element overwrite. Disposition: fixed under FRICTION #192; the two-compiler
tests/loop_temp_recycle.shand the default self-hosted benchmark cover it. - *secondary defect:* bench/window/run.sh uses tychoc1 by default, yet RESULTS.md claims about 4.3MB, a figure tychoc1 does not reach. Disposition: the default remains tychoc1; a fresh Linux run now measures 3.9 MB and matches the C checksum, consistent with the fixed loop path. The historical 4.3-MB result remains valid for its recorded build/host; rerun with
sh bench/window/run.sh. - *secondary defect:* Map delete also abandons the evicted value without recycling it. Disposition: fixed with mapC typed key/value recycling on delete; covered by the overwrite-scale enum deletion and
tests/map_delete.ty. - *secondary defect:* docs/memory-model.md says element-overwrite recycling closes the arena's known weak spots, but map overwrite still leaks, so the doc overstates the fix. Disposition: that historical path is absent in the current tree; the current
docs/internals/value-semantics-limits.mdnow documents recursive map-value and heap-array-element recycling and its limits.
D83 (addenda). tychoc1 rejects constructing a generic enum's variant through a package qualifier ('box.Has(41)', ...
- *secondary defect:* The failure is wider than reported: every way of constructing a package-qualified generic variant fails in tychoc1.
- *secondary defect:* A secondary diagnostic defect: the error has no file:line and shows the mangled name.
- *secondary defect:* _gen_variant in tychoc1's emitter does no package scoping at all.
D84 (addenda). tychoc1 builds per-iteration values in the function arena where tychoc uses the loop scratch or recycles: ...
- *secondary defect:* Overwriting a struct-with-string array element or a nested int array element grows memory under tychoc as well as tychoc1.
- *secondary defect:* Overwriting a [string] element through an inout parameter also diverges: 1.6 MB under tychoc and 64 MB under tychoc1.
- *secondary defect:* No RSS gate covers tychoc1, because bench-window and bench-guard default to tychoc.
- *refuted:* The suggestion to also fix struct/array equivalents is not a parity issue, because those element overwrites grow under both compilers.
- *correction:* The finder's ~10 MB floor came from measuring with rss.py under a python fork, so the verifier re-measured with bench/peakrss.c.
D85 (addenda). core:sqlite passes the database path and exec SQL to C as NUL-terminated char* with no interior-NUL guard: ...
- *secondary defect:* A third path the report missed: exec_params with no params, and query/query_params without parameters, also run the truncated SQL and return Ok.
- *secondary defect:* FRICTION #70 wrongly believes that passing len(sql) fixed the SQL path.
- *secondary defect:* FRICTION #78's claim that every string extern is length-carrying or NUL-guarded is false for sqlite3_open, sqlite3_exec and, in effect, sqlite3_prepare_v2.
- *correction:* exec_params failing closed in the report's case was accidental: binding failed only because the placeholder was truncated away.
- *correction:* Tycho's FFI is not at fault: it passes the whole buffer and its length. The truncation happens in SQLite.
D86 (addenda). The documented 'Some(1) == None' / 'opt == None' is rejected by both compilers. tychoc1's rejection is a ...
- *secondary defect:* docs/reference/enums-options.md (line 190-191 at b9df4aa) is wrong: it says 'Some(1) == None' evaluates to false, which contradicts the spec.
- *secondary defect:* tychoc1's typechecker also wrongly accepts comparisons with bare None, Ok and Err that tychoc rejects.
- *secondary defect:* The mangled-name problem in tychoc is not specific to None: inside a package, any diagnostic names that package's own types in mangled form.
- *secondary defect:* No reject fixture covers this form, so the parity gate misses it.
- *refuted:* The headline claim that the compilers should accept 'Some(1) == None' is refuted: the normative spec requires rejecting it.
- *correction:* The confirmed issues affect diagnostics only, with no miscompilation, so severity is low.
D88 (addenda). tycho-httpd answers '431 Request Header Fields Too Large' to a small head whose Content-Length exceeds the ...
- *secondary defect:* The wrong status is not limited to tycho-httpd. tycho-kvsrv, which does accept request bodies, answers an oversized 2 MB PUT with 431 instead of 413.
- *secondary defect:* tycho-httpd's 431 error page says the request headers exceeded the limit. That text is false when a small head only declares a large Content-Length.
- *secondary defect:* The documentation for the TooLarge variant disagrees with the code. The comment at httpd.ty (line 56 at b9df4aa) limits TooLarge to the no-terminator case, but the code also returns it when the framed size is over the cap.
- *secondary defect:* The MAX_HEAD comment in server/main.ty says requests past the byte budget get 431. The verifier says this conflicts with the httpd.ReqErr comment and with explain(431).
- *refuted:* The missing 413 (and other) entries in reason_phrase are documented behaviour, so they are not a defect by themselves. They only matter once something emits 413.
Lower-severity defects from the finders' notes
Each finder was asked for its ten best findings; these are the rest, as the finders described them. None was independently verified.
archives
- core:zip never checks the local-header name against the central-directory name, and a duplicate name always resolves to the first entry, a parser differential that can bypass name-based validation.
- json.stringify passes non-UTF-8 bytes through and can emit a document its own parser rejects.
- tycho-ar c follows symlinks, archiving targets outside the directory as regular files, and a self-referential symlink makes it die; neither is documented.
- docs/reference/corelib.md (line 709 at b9df4aa) omits the TooBig variant of ZErr.
codegen
- tychoc1 refuses the spec builtin pow(x, y) with a location-less internal error because emit.ty has no pow case.
- In tychoc, a user fn named __lam0 collides with the first lambda's generated symbol and the C build fails.
- tychoc1 reports a closure local that shadows a same-named fn as unused.
- Cross-package mangling collisions are refused in both compilers with a misleading same-package diagnostic that names the mangled symbol.
- The unknown-procedure hint for abs points at core:bignum instead of core:math, depending on readdir order.
- str() of an imported or generic struct prints the mangled type name.
- String literals are emitted without escaping '?', so under -std=c11, which several repo scripts use, trigraphs corrupt the string.
- ASan does not flag the arena free-list corruption because the arena manages its own memory.
db
- tycho-db accepts a CREATE TABLE with a duplicate column name; the second column can never be reached, whereas sqlite refuses it.
- The tycho-db CLI parses flags with the lax strings.parse_int, so malformed port and hook values silently bind wrong or random ports or disable the hook.
- The CLI ignores extra positional arguments, and a directory given as the script runs nothing and exits 0.
- MAX_LINE was soft: statements of 8193-12287 bytes still executed. Fixed 2026-10-15: the first line is checked before splitting, excluding LF/one final CR, and reads are bounded by remaining framing headroom. Exact/over-limit LF/CRLF, fragmented and pipelined cases are tested. TooLong still closes the connection (possibly a TCP reset), without an in-band error; that protocol behavior was not changed. See the peak-driven follow-up.
- The lex error for a non-ASCII byte echoes a single byte of a UTF-8 sequence, putting invalid UTF-8 on stdout and the wire.
- tychoc emits two 'is copied into this aggregate' warnings for exec.ty that tychoc1 does not (warning parity gap).
net
- Unreproduced suspicion: tls_shim.c tcp_connect_timed casts the millisecond timeout to int for poll() with no INT_MAX bound, reachable only through the package-private tls._connect_timeout.
- Unexercised suspicion: http_shim.c formats the content type into a 512-byte buffer, so a longer value would be silently truncated.
parity
- Both compilers run out of memory on polymorphic recursion at a doubling type because the 1024-instance cap never fires.
- reserve(xs, -1) is a no-op in both compilers instead of aborting as the spec requires for a negative capacity.
- tychoc1 refuses literal adaptation in send(c, 3) on sized channels that tychoc accepts, the method form skips the range check, and the diagnostic prints internal type spellings.
- The range diagnostic for a u64 literal prints the int range instead of the u64 range.
- t.0.0 fails in both compilers because 0.0 lexes as a float.
- A const used as a binary operand with a sized operand compiles in tychoc but is refused by tychoc1.
- Smaller parity gaps exist on nested subscript assignment, inout on a map element, and field access through a struct newtype.
- exit(256) exits with status 0 in both compilers, while the spec says exit terminates with status code.
program
- A default tychoc1 build of the app gets a gcc -Wstringop-overread warning from tycho_str_concat3; tychoc is silent, and the emit-warn lane cannot see this class.
- json.TooDeep always reports byte 0, contrary to docs saying every refusal carries the offending offset.
- A comment in json.ty for JFloat references a header section that does not exist.
- httpd.reason_phrase is missing several status codes and prints a generic 'Status'.
- core:io has no rename, so atomic file replacement such as compaction cannot be implemented.
- A user fn named find gets both a builtin-collision warning implying it is legal and an 'already defined' error.
- tychoc emits 'copied into this aggregate' lints that tychoc1 does not.
- The tychoc-built tycho-kvsrv also grows a small amount of memory per GET; not investigated.
- Plain ASan cannot detect arena use-after-free bugs because freed arena blocks go to a thread-local pool.
scale
- tychoc silently drops imports past the 256th in a package, then reports a false missing-symbol error; tychoc1 builds the program.
- Warning emission is quadratic in both compilers because each warning rescans the source from byte 0 for its excerpt.
- Several lookups are quadratic in both compilers (sig_find, _sorted_decls selection sort, enum variants, many locals).
- tychoc1 refuses a non-recursive chain of 65 generic functions with a false 'recursive generic' message; tychoc's cap counts total instantiations and gives the same misleading message.
- Compiler limits differ between tychoc and tychoc1 (string-literal pieces, struct count, loop nesting, type nesting), and tychoc's block-nesting error has no file:line.
- Constructing a struct with 10,000 fields takes quadratic memory in tychoc as well.
Refuted
Kept so the next session does not re-report them.
- (db) No checksum covers the WAL header's base_lsn: one flipped bit silently drops committed rows or replays them twice -- The reported behaviour reproduces exactly, and identically under both compilers. I rebuilt tycho-db from with ./tychoc1 and with ./tychoc, and the output of both binaries matches the report. It is still not a defect against anything tycho-db promises. It asks for a guarantee the tool never makes: detecting media bit-rot. (1) The store has no integrity check at all. A one-byte flip in a checkpointed row silently turns 7 into 8 with rc=0 (min leg E). A flip in StoreHdr.applied_lsn skips the committed record in exactly the same way (leg C). An unchecked 8-byte base_lsn in the log header is the same class of gap as the rest of the files, not a hole in a protected design. (2) The run.sh [3e] ...
- (D31) The claim that tychoc1 is near-linear does not hold. tychoc1 is also super-linear, just with a smaller constant.
- (D64) The part of the claim about sizes and offsets of 4 GiB or more was not demonstrated. The to_u32 wrap exists in the code but is effectively unreachable today.
- (D65) Only the deflate side is defective. The uInt casts on the inflate side (zx_decompress, zx_raw_inflate) fail closed.
- (D66) The '[a]]' case does not count as a defect. It is the same documented looseness about bare-key characters as 'a b = 1'.
- (D69) The trailing-junk acceptance, and failing open where Python refuses, is a documented decision in the audit, so that part of the report is not a new defect.
- (D69) The spec does not require rejecting trailing junk, so the report's call to refuse it like Python does is only a suggestion.
- (D76) The verifier does not count the offset-70 byte flip that silently changes a row as a decode bug. It produces a structurally valid value that only a checksum could catch, so it is a design gap.
- (D77) Part (b), a missing store beside a checkpointed log opening with only the post-checkpoint suffix, was not confirmed as a defect. No crash can produce that state, and run.sh documents it as accepted behaviour.
- (D79) The claim that WHERE columns are checked only against examined rows was refuted, along with the report's third pair. The scan path behaves the same way, so this is not specific to the index.
- (D79) The conjunct-reordering divergence falls under the lazy, order-dependent semantics that the scan path also has, so it is not a separate index defect.
- (D84) The suggestion to also fix struct/array equivalents is not a parity issue, because those element overwrites grow under both compilers.
- (D86) The headline claim that the compilers should accept 'Some(1) == None' is refuted: the normative spec requires rejecting it.
- (D88) The missing 413 (and other) entries in reason_phrase are documented behaviour, so they are not a defect by themselves. They only matter once something emits 413.
Observed during the session, outside any lane
- FRICTION #132 records a gap that does not reproduce here. Its last paragraph says tychoc1 refuses -3.0 in a vector[4]f32 literal and that the fixtures write 0.0 - 2.5 to dodge it. On b9df4aa both compilers accept v: vector[4]f32 = [1.0, -2.5, 3.0, -3.0], as a declaration, a reassignment and a call argument, and print the same values. The entry should be re-checked and corrected.
- tychoc1 warns about a vector type in the same run where it rejects it. For w: vector[4]i32 = [1, 2, 3, 4] it prints the over-wide warning "vector[4]i32 is 32 bytes but the baseline vector register holds 16" (sizing i32 as 8 bytes) and then the error "a vector element must be int, float or f32 -- i32 is not". tychoc prints only the error.
Historical partial-fix snapshots from the baseline session
This section records two proposed diffs as they appeared when the original session stopped. Their State notes are historical, not a description of the current working tree: later remediation was implemented and documented in the audit rows and FRICTION ledger. The current tree is still uncommitted, and current validation status is summarized above.
Size-multiply overflows before an allocation (g1-alloc-overflow)
Staged, uncommitted work in /home/user/fix-g1-alloc-overflow (base b9df4aa), 24 files. Also saved as scratchpad/partial-g1-alloc-overflow.patch. It fixes five size-overflow defects. 1. The array reserve bound was too loose (runtime/tycho_rt.c@tycho_cap_check). The limit changes from SIZE_MAX/elem to PTRDIFF_MAX/elem. Before, 8*(2^61-1) passed the check and then wrapped inside arena_alloc. The patch also adds a backstop in block_get: it calls tycho_oom when cap > PTRDIFF_MAX - sizeof(HBlock). Fixture: tests/abort/reserve_range_top.{ty,err}. 2. Reserve on struct-element arrays had no cap check (src/tychoc.c@gen_program, the tycho_arr_C%d_reserve emitter, and compiler/emit/emit.ty@_arr_defs). Both compilers now emit a call to tycho_cap_check(n, sizeof(et)). In tychoc.c the fprintf gets one extra 'ct' argument to match. Fixture: tests/abort/reserve_composite.{ty,err}. 3. Map reserve could spin forever in its doubling loop. The patch adds runtime/tycho_rt.c@tycho_map_cap_check, which aborts when n > 2147483000. It is called in tycho_map_{si,sf,ii,if}_reserve, in tychoc.c's tycho_mapc%d_reserve and in emit.ty@_map_defs. In tychoc.c the ec/ic loop previously ran BEFORE the 'cap <= 0' guard; it is now moved after the guard and the new check. Fixtures: tests/abort/reserve_map_range and reserve_mapc_range (.ty/.err). 4. Channel capacity could overflow (runtime/tycho_rt.c@tycho_chan_new). It now aborts with "channel capacity N out of range" when cap > PTRDIFF_MAX/sizeof(HCell)/2. Separately, ty_aligned_alloc64 (_WIN32 branch only) now returns NULL if n + 71 would wrap. Fixtures: tests/conc/abort/chan_cap_{spin,wrap}.{ty,err}. tests/rtparity/run.py gets the new message in EXPECT_MSG_RUNTIME. 5. Regex group index was unchecked (corelib/regex/regex_shim.c@rx_group). An n above re_nsub now returns -1 before malloc. Doc comments in corelib/regex/regex.ty@group_start and @group are updated. There is a new 'grp_big' case in corelib/test/regex/main.ty, plus a line in regex.out. Supporting changes: - tests/run.sh: flat abort binaries now run under 'ulimit -t 15', so a hang counts as a failure. - compiler/verdict_diff.py: EXPECT goes from 1431 to 1437, which counts the six new abort fixtures. - docs/spec/17-runtime.md: the abort list now covers an oversized channel capacity. - docs/spec/appendix-e-conformance.md: the §30.2 row lists three of the new fixtures.
State: Never gated; nothing in the diff shows any lane was run. What the diff shows: - The six .err goldens and the regex.out line appear to be written by hand, not recorded. Flat tests/abort goldens are compared exactly with cmp, so any wording or newline mismatch will fail. - The chan_cap .err files leave out the "tycho: " prefix. That works because tests/conc/run.sh matches with a substring grep. - The EXPECT=1437 count assumes all six fixtures reach both compilers. It has not been checked. - Appendix E lists reserve_composite, reserve_map_range and chan_cap_wrap, but not reserve_range_top, reserve_mapc_range or chan_cap_spin. - Two limits are policy choices that no longer follow the element size: the map cap is hardcoded at 2147483000, and the channel bound is roughly 2^55 cells. - tycho_cap_check is static. The per-type reserve both compilers emit relies on it and on tycho_map_cap_check being in the same translation unit, as the patch comment notes. - The ty_aligned_alloc64 change is Windows-only and has no fixture. - No FRICTION or changelog entry is included. - The work is still uncommitted.
The interior-NUL sweep (g2-nul-sweep)
Staged in /home/user/fix-g2-nul-sweep (base b9df4aa), 29 files. The change finishes the interior-NUL sweep from FRICTION 75-78 and addresses four defects. 1. core:crypto hex arguments were cut at a NUL. 'corelib/crypto/crypto_shim.c@hexdec_ct' and '@hexdec' now take 'tycho_int slen' and no longer call strlen. A negative length fails closed. All 12 hex-taking 'cx_*' externs gained a '*len' parameter, and the wrappers in 'corelib/crypto/crypto.ty' pass 'len(arg)'. '@cx_ct_equal' drops its separate strlen comparison (the FRICTION 76 check). In 'scripts/crypto_hygiene.sh', the prototypes and calls are updated, NUL-inside-length accept/reject cases are added, and the valgrind strlen suppressions are removed. New fixture legs: 21 'hexnul_*' lines in 'corelib/test/crypto/main.ty' and 'crypto.out', covering sha, hmac, key import, ed25519 sign/verify forgery, aead, x25519 and pbkdf2 salt, with controls. 2. core:sqlite 'open', 'exec' and '_run_stmt' acted on the SQL or path before the NUL. 'corelib/sqlite/sqlite.ty' adds a private '_has_nul' and a '_NUL_SQL' const, refuses with Err, and puts a '# nul: guard' verdict on 'sqlite3_prepare_v2'. New 'sqlnul *' legs in 'corelib/test/sqlite/main.ty' and 'sqlite.out', with helpers 'ires' and 'count_v'. 3. 'io.read_line' / 'fold_lines' cut a line at a NUL. 'corelib/io/io_shim.c@iox_read_line' now returns 'bytes' with an inout status and an exact-size malloc copy. 'corelib/io/io.ty@_next_line' wraps it. New 'nul_content_checks' in 'corelib/test/io/main.ty' and 'io.out'. 4. 'os.run' / 'exec_out' cut output at a NUL. 'OsRun' gains 'len'. 'corelib/os/os_shim.c@osx_run_out' now returns 'bytes' and hands the buffer over by setting the handle's 'out' to NULL. 'osx_spawn' and 'osx_spawn_win' pass 'outlen'. New 'out_nul_run' and 'out_nul_exec_out' legs in 'corelib/test/os'. Also added: 'scripts/nul_extern_check.py', a new checker with a selfcheck. It runs in the Makefile 'surface-check' target. The '# nul:' verdict it reads is added on 'corelib/strings/strings.ty@strx_parse_double'. The three census goldens were re-recorded, and docs were updated: SECURITY.md, CONTRIBUTING.md, audit-brief, builtins, corelib.md, spec 14 and 18. The docs also withdraw the base64/hex/url "0x00 caveat".
State: Missing or not visible in the diff: - No FRICTION.md entry (the last entry is still 132). The docs mention "FRICTION 75-78" and "#78" but nothing is added to record this change. - No 'surface.lock' change, even though several extern signatures changed. Whether that file needs a change is unverified. - The census goldens were re-recorded, so the counts shifted (for example 'iox_read_line' 80 to 40, 'to_str' +58). Their provenance can't be checked from the diff. - The comment at compiler/emit/emit.ty (line 8139 at b9df4aa) still names 'iox_read_line' as the example of an 'Option(string)' return. That extern no longer returns one. Risks visible in the diff: - 'iox_read_line' sets '*out = NULL' with length 0 for an empty line. This relies on the runtime bytes wrapper accepting a NULL pointer. - 'osx_run_out' is now destructive: a second call on the same handle returns empty. - The docs remove the codec 0x00 caveat and assert that 'chr(0)' appends a byte. That rests on existing behaviour this diff does not show or test. - The crypto test calls 'key_free' on handles that may be null. - The Windows spawn path changed but has no host test, as its own comment says. - 'nul_extern_check.py' matches regexes against argument text and looks at only 8 lines per call, so its verdicts are heuristic. It was never gated. Nothing in the diff shows lanes run or passing.