Repository navigation
Bound the hook's worst-case inputs, and make the host a project remembers consistent - #64
k4rthiknerella wants to merge 29 commits into
Conversation
lutic1
left a comment
There was a problem hiding this comment.
@k4rthiknerella, the token, leading-cd and nesting bounds work, and a cut never hides a failed test. But the PR says the hook's worst-case inputs are now bounded, and two shapes still blow past the 5 s hook timeout. Please fix:
Blocking
-
internal/shape/masked.go:121:runShells(body, g)runs over the whole line once per runner group, which is quadratic. Measured withDerive:make t0;make t1;…with 16k runners (181 KB): 40.9 s.make tN|tail;with 4k runners (63 KB): 26.6 s and 4.4 GB allocated.
Cap the groups (e.g. return null when there are more than 64) or evaluate all groups in one pass. Add RES-29 rows for both shapes.
-
internal/shape/hosts.go:445:collectrestartsauthority()at every scheme match, and@isn't a terminator. Thehttps://[branch at:481has the same problem throughstrings.Index(s,"]"). Measured withshape.Hosts:- 100 KB of
git@: 7.8 s. - 400 KB of
git@: 2 min 4 s. - 900 KB of
https://[: 5.8 s.
Limit
authority's scan to about maxName+8 bytes, and look for]only inside that window. - 100 KB of
Should fix
3. DeriveCut and HostsCut exist, but the hook calls Derive/Hosts (handle.go:107/115, post.go:181), so the cut flag is never recorded. ssh hosts past the token budget go missing, and the shorter list is presented as complete. Until schema 5 ships, record the ssh list as null when it was cut.
4. Stored hosts aren't migrated. Fold and novelHostCandidates compare keys exactly as stored, so after an upgrade a host stored as 167838211 and later named as 10.1.2.3 reads as novel. Run host.Canonical over keys in load, merging entries, and add a test against a pre-upgrade baseline.
5. TestRubricLLD6_JoinsAreSearchedNotScanned (rubric_lld6_test.go:29) uses a 2 s wall-clock limit and took 4.2 s under -race. That contradicts "gated on allocation, never wall time". Gate it on allocation.
Nit
6. Old records and baselines can hold host values like api.example.com=ghp_…, and nothing cleans them up. Worth a release note, or a cleanup pass.
Generated by Claude Code
lutic1
left a comment
There was a problem hiding this comment.
@k4rthiknerella, this is a follow-up to the earlier review: an adversarial pass over this PR. Please fix each item, or reply on the PR saying why it doesn't apply. The earlier quadratic-time blockers in masked.go and hosts.go still stand.
Bounds that don't hold
hosts.go:185/201sshCommandHosts: the shared budget only decides whether the next line gets read, and each line is still lexed up to the fullmaxTokens. Worst case is about 2× the bound, outside the 268 B/token budget. Pass the remaining budget into each line's lexer.tokenize.go:582tokenEstimatecounts blanks without regard to quotes, so one quoted 8 MiB string with spaces makes everylexcall preallocatemaxTokens+1tokens, across 5 or more readings. Track quotes or cap the initial capacity, and add this shape topathological.Lines.handle.go:202startDirectory→LeadingDirectorydecodes and lexes the whole line again, even thoughDerivealready computedleadingRun, andHostslexes it once more. The budget assumes 3 readings; the hook does at least 5. Reuse the decoded string andDerive's result.res29_test.go: the peak is 59.5 MiB against a 64 MiB limit, measured only on darwin/arm64, with no wall-time check under-race. Add a portable allocation bound per reading, run the test on linux/amd64 in CI, and fail at about 80% of the limit.
Host canonicalisation
host.goinetAtoninsideCanonicalrewrites every all-numeric host:https://1234/becomes0.0.4.210, and0becomes0.0.0.0, whichLoopbackthen treats as this machine. Numeric ssh aliases would be rewritten too. Limit inet_aton folding to the inputs where clients actually do it, and test numeric aliases.host.go:119-129vs:231:Canonicalaccepts zoned IPv6 ([fe80::1%eth0]) butShapedrejects it. The link-local host is then dropped from declarations while still being compared on the sandbox side, which gives a false "reached but never named". Pick one rule, and testShaped(Canonical(x))for every accepted x.fold.go:59/destinations.go:198:Foldkeys on the exact stored string, so pre-upgrade spellings (167838211, uncompressed IPv6) read as novel and fail to match the sandbox trail. Re-key throughhost.Canonicalinload(), merging entries. (This was also raised in the earlier review.)
Baseline behaviour
destinations.go:200:ErrTooManyHosts,ErrLockTimeoutanderrTooLargeall render as "could not be read or written", so users may delete a healthy baseline. Give each its own reason.Result.Evictedis computed but never shown, so a host that returns after eviction at the 4096 cap reads as novel. Surface it in the report.fold.go:38maxEntryBytes = 1 KiBdoesn't cover the worst-case JSON escaping of a 256-byte session id (about 6×). Once the file exceedsmaxBaselineBytes, every later save is refused and the project's baseline is stuck permanently. Derive the size from the worst case, or evict until it fits.clearBaselines(main.go:1824) takes itsReadDirbefore taking the per-file locks, so a baseline created by a concurrent render is never visited, and the forgotten host survives. Holdbaseline/.lockacross both.
Cut flag
HostsCuthas no callers, so an ssh list cut at the bound is presented as complete. Wire it in, or drop it until schema 5 (also raised earlier).hosts.go:185-193:cutis set when the rest is only blank lines, and is not set when a line stopped as uncertain or unterminated. UseTrimSpace(rest) != "", and decide whether an uncertain stop also counts as a cut.
Generated by Claude Code
The lexer kept the word being built in five loose variables and reset them by hand: flush cleared started, qat, opaque and ticks one at a time and begin set glued in a second closure. A field added to a token and missed in that list carries one word's quoting, expansion or backtick count into the next word, and nothing would notice. The per-word state is now a word struct (text, started, glued, quotedAt, opaque, ticks), reset whole with newWord when a word is emitted. The bytes between words, gap and sawNL, stay with lex because they belong to no word. Behaviour is unchanged: every tokenizer, program and masked-status test passes as before. TestRubricLLD9_WordResetClearsEveryField sets every field of word to a non-zero value through reflection, so a field added later is covered without editing the test, and requires reset to give the fresh word. Mutants: a field-by-field reset that forgets ticks and glued, and a fresh word not quoted at -1 (RUBRIC_lld-9_word-reset.py). The H-13, MS and tokenizer mutants that anchored on the loose variables are re-anchored on the struct fields and are still red. Rubric fix list entry 66 (lld-9).
A hook payload is read up to 8 MiB, which bounds the bytes and not the
work. 8 MiB of one-letter words is four million shell words, and the
shape of that one line allocated 4.5 GB (TotalAlloc, measured in the new
test before the bound) and drove a hook to about 1.07 GB of RSS.
Lexing now stops at maxTokens = shapeBudget / bytesPerToken: a 16 MiB
budget over the whole Derive's allocation per token on the 4 KB line,
BenchmarkDerive/bash_long_4k's B/op over its 1,068 tokens, rounded up.
That is 395 B and 42,473 tokens. The cost is the whole Derive's because
Derive reads a line three times and the leading-cd count and the hook's
LeadingDirectory read it again; one reading costs about 106 B per token.
TestRES9_BoundMatchesItsArithmetic holds the constant to the formula and
the formula to the budgets.json row, so a change that moves Derive's
cost per token has to restate the bound with it.
Past the bound the lexer returns the first maxTokens tokens with
errTokenBound, which Derive reads as uncertain: argc and the masked
status are null, the line may write, and the program is named only when
its command ended before the bound. A python3 here-document keeps its
program; 8 MiB of `echo a a a` does not, because a command still open at
the bound could be zsh's `echo a ... () { }`, a function definition.
Two allocations went with it, without which the line still allocated
56 MiB: the token slice is sized once from a count of word ends and
operators (held to one past the bound) instead of grown, since append's
copies cost about four times the slice; and the shell digest is written
through a buffer of at most 4 KiB instead of a copy of the whole line.
Derive over the 8 MiB line now allocates 23 MiB. On the benchmark lines
bash_short drops from 4,624 to 3,280 B/op, bash_long_4k from 430,688 to
421,153 and BenchmarkLexTokens from 128,544 to 112,848; those three rows
are restated (best of three, -benchtime 20x, this machine) and
budgets_gen.go regenerated. The RES-36 margin mutant is re-anchored on
the restated bash_short row, and the H-14 and TB digest mutants on the
string form of digest.
Tests: TestRES9_BoundMatchesItsArithmetic, TestRES9_LexerStopsAtTheBound
(each of the three readings at N, N with trailing blanks, N+1 words and
N plus an operator), TestRES9_TokenBoundCapsShapeMemory (8 MiB of short
words, TotalAlloc at most 32 MiB), TestRES9_HeredocKeepsItsProgram (an
18,005-token here-document is read whole and names python3; one past the
bound still names python3 with argc null). Mutants in RES-9_token-bound.py:
the bound lifted to 1 << 30, the lexer reading on past it, the extra
token handed back, the cut-off line read as certain, and the slice grown
instead of sized.
Not done here: the record cannot yet say its written paths and hosts are
incomplete past the bound, which RES-9 asks for; those fields are schema
5 (written_paths_complete) and land with P-11.
Rubric fix list entry 29 (lld-6), RES-9.
startDirectory folded every leading `cd DIR &&` onto the payload's cwd, joining and cleaning the whole growing path at each step, so the fold was quadratic: a 480 KB payload took 10.9 s against the 5 s hook timeout, and SIGTERM does not stop the work. Measured here before the change, 14,000 steps (the most the new token bound lets through) took 290 ms. shape.MaxLeadingCd = 64 is the bound, written down beside its reason. The hook folds no more: past it startDirectory returns the empty directory, which CWDDigest records as unknown, rather than the payload's cwd the cds moved away from or a fold stopped short of where the line ran. leadingDirectories stops reading one step past the bound. The masked-status walk holds the same bound: past it no step counts as held by the cwd digest, so the runner after them gets a null runner digest instead of one that would pair with a run in another directory. pastDirectoryChange, the program search's move past each leading cd, is a loop instead of a call per cd, since the step count is the input's to choose (CWE-674). The program is still named past any number of cds; the bound is the fold's, not the program's. The comment on MaxPayloadBytes said the read limit closes the out-of-memory path. It bounds the bytes read, not the work done on them; the comment now says so and names the two bounds that do the rest, the token bound (RES-9) and this one. Tests: TestRES10_LeadingCdFoldIsBounded (64 steps fold, 65 are unknown; a 1 MB `cd a&&` payload and 14,000 steps each fold within 50 ms, least of three runs), TestRES10_RunnerPastTheFoldIsNotPaired (64 and 65 steps through leadingSteps and Derive's runner digest), TestRES10_ProgramPastManyDirectoryChanges. Mutants in RES-10_leading-cd.py: the hook's bound lifted, the shared bound lifted, and the walk counting steps past the bound. The two FF mutants on pastDirectoryChange are re-anchored on the loop body and still red. Rubric fix list entries 14 (lld-1) and 29 (lld-6, the loop and the comment), RES-10. CWE-407, CWE-674.
maxNest is the only barrier between a line of nested substitutions and a
stack overflow, which is a fatal error that exits 2, and exit 2 from a
PreToolUse hook blocks the tool call. Nothing tested it: no test named it,
and raising it to 1 << 30 passed every suite while its binary crashed on
a 4.6 MB payload.
TestRES29_NestingAtAndPastMaxNest has one row per construct that
recurses: a quoted string inside each substitution ("$(echo " costs two
levels, so the flip is between 16 and 17 pairs), a substitution inside a
substitution and arithmetic inside arithmetic (one level each, 33 and
34). Each line is `cd "..." && ls`: at the deepest accepted level the
program is ls, one past it the reading gives up inside the word and the
program is null, and both answers come within 50 ms.
Mutants in RES-29_max-nest.py: the bound lifted to 1 << 30, moved to 31
and to 33, and the check dropped from each of comsubEnd, dquoteEnd and
arithEnd. The code is unchanged.
Rubric fix list entry 16 (lld-5), the maxNest row of RES-29. CWE-674.
…named hosts Two defects in how a hostname reaches the record. Shape and length (lld-2). Only the ssh path asked whether a token was a host; the URL path took whatever authority() cut at the command line's punctuation, and `=` and `!` end neither a URL nor a host. So `curl https://api.example.com=ghp_...!x/` recorded the host "api.example.com=ghp_...!x", a token in the field that holds the hostnames a call named, and with no length limit an authority of 300,000 bytes was recorded whole. host.Shaped is now the one predicate: a bracketed IPv6 address with no zone, or a name of at most 253 bytes of ASCII letters, digits, `-`, `_` and `.` or non-ASCII letters, marks and digits, with no empty label. shape.collect asks it after Canonical, for Bash and WebFetch alike, and the ssh destination path asks it in place of its own ASCII-only copy, which is removed. Canonical itself takes only the 253-byte bound, since it also serves the sandbox reader. The wire-side half of the entry (a Refused counter on the proxy's Observation) is moot: internal/wire was deleted with the proxy. Spellings (lld-4). Canonical lower-cased and stripped ports but kept IP literals as written, while a client normalises them before it connects: curl turns [2606:4700:0:0:0:0:0:1111] into [2606:4700::1111] and 167838211 into 10.1.2.3. The proxy comparison this broke is gone, but the consequence is live in two places: the novelty baseline, where one address in two spellings is two hosts and the second reads as new, and the sandbox reader (internal/nono), whose targets are compared with the declared hosts. IP literals are now parsed with net/netip and written once: IPv6 compressed, lower-case and bracketed (a bare one gains its brackets), an IPv4-mapped address as the IPv4 address it maps, and every form inet_aton reads (167838211, 0xa010203, 012.1.2.3, 10.1.515) as a dotted quad. Numeric-looking names that are not addresses (1.2.3.4.5, 256.1.1.1, 08.1.2.3) stay names. A bracket that holds anything but an IPv6 address, and a colon outside one, are refused. net/netip is not package net: it opens nothing, and net/url already linked it into the recorder, so H-17 holds unchanged. Loopback was a list of five spellings in the report, so [0:0:0:0:0:0:0:1], 127.0.0.2, 2130706433 and api.localhost were offered to the baseline as possible new destinations. host.Loopback decides it on the parsed address (127.0.0.0/8, ::1, the unspecified address) and on localhost and names under .localhost (RFC 6761 6.3). IDNA is not implemented: it needs golang.org/x/net/idna, a new module dependency and a THIRD_PARTY_NOTICES entry, which is a decision this change does not take. The comment that justified its absence by "the two sides receive the same bytes" was false and now says what is lost: a Unicode name and its xn-- form are two hosts, and every such mismatch errs toward a host read as new, never one hidden. The proxy-era comments in the package doc and in Canonical's are rewritten for the readers that exist. Tests: TestRubricLLD2_ShapedAcceptsHostnamesOnly, TestRubricLLD2_CanonicalBoundsItsLength (253 and 254 bytes), TestRubricLLD2_URLHostsAreHostnameShaped (the `=` and `!` rows, 300,000 bytes, 253 and 254, an IDN, an IPv6 and an inet_aton URL, and WebFetch), TestRubricLLD4_SpellingFamiliesCanonicaliseAlike (six families including 10.1.2.3 against [::ffff:10.1.2.3], and names that must stay names), TestRubricLLD4_LoopbackIsParsed, TestRubricLLD4_LoopbackSpellingsAreNotNovel. TestCanonical's bare-IPv6 row now expects the bracketed form. Mutants in RUBRIC_lld-2_host-shape.py (five) and RUBRIC_lld-4_spellings.py (eight); the H-70 shape mutant is re-anchored on host.Shaped and still red. Rubric fix list entries 10 (lld-2) and 30 (lld-4). CWE-20, CWE-176.
…changed Rendering a report did an unlocked load-modify-save of the project baseline, and `forget --host` rewrote the same file with no lock either. Two concurrent renders each loaded the file, folded their session in and renamed their copy into place, and the second rename threw away the first's host: 200 of 200 rounds of the review's probe, and round 0 of the new test here. A later session was then told a host was new that an earlier one had named, and a forget and a render could each undo the other's change. The decision is now a pure Fold(file, existed, session, start, hosts), which returns the new file, the result and whether anything changed, and Update is a locked load, Fold and save around it. The lock is a sidecar file, baseline/.lock, because the save renames over the data file and a lock on the replaced inode would guard nothing. It is the store's own bounded flock (store.LockPath, a thin export of lockFile with the 2 s budget), so the timeout behaviour is the store's; a lock that cannot be had renders novelty as unknown. ForgetFile takes the same lock. The host map had no cap and every render rewrote it whole. It now holds at most maxBaselineHosts = 4,096 hosts (RES-11's figure), with the reasoning beside the constant. Each entry gains last_seen_at_unix_ms (additive, omitted when zero, version unchanged), and past the cap the least recently seen hosts go first, never one the folding session named; the count is on Result.Evicted. A session that alone names more than the cap cannot be folded without dropping its own hosts, so it reports ErrTooManyHosts (novelty unknown) and writes nothing. That settles the policy difference between the rubric (report unknown at the cap) and the spec (evict the least recently seen): evict where eviction can be right, unknown where it cannot. A render that changes nothing, such as a second render of one session, no longer rewrites the file. A baseline file is read through a bound, maxBaselineBytes = 4,096 entries of at most 1 KiB plus 64 KiB of header, and a larger one is read as corrupt (novelty unknown) instead of read whole; a save that would write a larger one is refused. The spec's 256 KiB per file does not hold beside its 4,096 hosts: an entry takes about 150 bytes as written, so 4,096 take about 600 KiB, and at 256 KiB the effective cap would be about 1,700 hosts. The TRU-58 acceptance helper that found "the one project's file" under baseline/ now counts only *.json files, since the lock file sits beside them. Tests: TestRubricHLD2_ConcurrentRendersLoseNoHost and TestRubricHLD2_ForgetAndRenderLoseNoChange (40 rounds each, through internal/safe goroutines; both failed at round 0 before the lock), TestRubricHLD2_FoldIsPure, TestRES11_EveryCapAtNAndNPlusOne (the baseline's row: 4,096 and 4,097 hosts, the least recently seen going, an old session's own host kept, and 4,097 and 4,096 hosts named by one session), TestRubricLLD10_AnUnchangedRenderWritesNothing, TestRubricLLD10_AnOversizedFileIsUnknown (exactly the bound is read, one byte more is not). Mutants: RUBRIC_hld-2_baseline-lock.py (three), RES-11_baseline-hosts.py (five, including the spec's "remove the 4,096-host check"), RUBRIC_lld-10_baseline-writes.py (three). Not done: the spec's 128-file cap on baseline/ (least recently written project file removed) and a health line that shows evictions. Rubric fix list entries 28 (hld-2) and 53 (lld-10), RES-11's baseline row. CWE-362, CWE-400.
Most input bounds were never tested at their edge, so a bound could move by one, or the reader applying it could read one byte past it, and every suite would pass. One table test per bound now holds the value and the edge: - store maxLine (16 MiB): a record line of exactly maxLine bytes is read, one byte more fails the read with bufio.ErrTooLong and hands back no part of the record; - recap maxTrackedSessions (64): 64 sessions are all kept in both maps of recap.json, 65 are cut back to 64; - spend maxTokens (100,000,000): exactly the bound is plausible and one more is not, in each of the seven fields a usage carries; - spend maxLine (64 MiB): a transcript line of exactly maxLine bytes is read and its response priced, one more marks the file unreadable; - report messageCap (64 Ki runes): exactly the cap is kept whole, one more rune is cut back to it; - report transcript maxLine (64 MiB): a line of exactly maxLine bytes has its tool call collected, one more stops the read with an error. The edge tests found what the three line bounds mean: they count the line's newline, because bufio.Scanner needs room for the terminator to find it, so the longest line read is maxLine-1 bytes. The comments on the three constants now say so. The payload cap was already tested at N and N+1 by RES-4 (TestRES4_CapAtNAndNPlusOne); maxNest, the token bound and the leading-cd bound have rows of their own in this series. Mutants in RUBRIC_test-8_bounds.py: each value moved, the store and transcript readers taking one byte past the bound, spend reading one short, recap keeping one session fewer, an exact maxTokens refused and the message cut one rune short. All eight are red. Not changed here, and worth a decision: recap's cap evicts in map order, which can drop the session it has just recorded and lose that turn's idempotency once the file is at the cap. Rubric fix list entry 17 (test-8).
leadingDirectories gave up on any lexer error, the token bound's included, so a Bash line that ran past 42,473 tokens with a leading cd kept the payload's cwd: `cd /elsewhere && go test` and 50,000 words, or `cd sub && python3 - <<EOF` and a long script, recorded a cwd_digest for a directory the line moved away from, and a 1 MB payload of `cd a&&` recorded the payload's cwd instead of the unknown directory past 64 steps. Before the token bound the first two recorded the right directory. A line cut at the bound is now read to the bound, since its leading steps come first. The most the run reads is MaxLeadingCd+1 steps of three tokens, 195, and a blank constant fails to compile if that ever stops fitting inside maxTokens, so the cut never falls inside the run. Any other lexer error is an uncertain reading that may begin in the run, and still reports false. The masked-status walk shares leadingDirectories, so the steps it counts as held stay the ones the hook folds into the cwd digest. TestRES10_LeadingCdFoldIsBounded now asserts the unknown directory for the 1 MB and 14,000-step payloads and four lines past the token bound, and TestRES10_LeadingStepsPastTheTokenBound covers the masked-status count. The shared-bound mutant lifts MaxLeadingCd to 128, since past the bound's arithmetic the package no longer compiles; a new mutant restores the old give-up-on-any-error check.
lex was 244 lines against the 100-line limit (Law 10): one loop holding its state in locals and five closures, with each construct's reading written inline in a case of its switch. The state moves to a lexer struct, the closures become its methods, and each case calls a method that reads the construct beginning at s[i] -- a blank, a comment, an escape, a single- or double-quoted string, an operator or a plain byte -- and returns the index of its last byte. lex is now the loop and the dispatch, 37 lines; the longest method, double, is 55. The behaviour is unchanged: each method is the case it replaces, with the locals read through the lexer, and an error returns the tokens emitted so far, as before. The 24 mutants anchored inside lex are re-anchored on the methods with the same changes, and each is still turned red by the tests it names.
The masked-status walk reads a here-document's body line by line, and for each line joinedIn and delimLine walked every offset at which the joining removed a backslash-newline. A line of continuations followed by a body of short lines therefore took time in the product of the two: 160,000 of each, an 800 KB payload, took 13.8 s against the hook's 5 s timeout, and the hook cannot stop the work once it has started (CWE-407). It is the rubric's lld-6 class, work on a payload the read limit does not bound. The offsets ascend, since the joining appends them as it writes, so both readers now binary-search for the first offset past where they look and stop at the first past where they end. The same line takes 40 ms. The answers are unchanged: TestRubricLLD6_JoinedInKeepsItsBounds pins each edge of joinedIn and delimLine's three outcomes, and the timing test also checks that the runner after the body is still read and a continuation inside the body still gives the reading up. Three mutants restore either scan or move the search's edge.
The token bound (RES-9) held one 8 MiB shape, short words, and left the others where they were, measured on the hook binary against RES-29's 64 MiB: 87 MiB of RSS for a line of backslash-newlines, 84 for one word of $a, 173 for 8 MiB of empty lines and 178 for `ssh a` lines. Each came from a reading the bound does not reach. - A word's text is now the slice of the line it was read from while its bytes are the line's own and lie together, and is copied only when quote removal or an escape leaves a gap; a copy grows by doubling. Copied byte by byte, append growing a quarter at a time, one 8 MiB word cost five times its length in each of three readings. - joinContinuations keeps maxJoins (= maxTokens) offsets and records that it dropped the rest (joined.lost). Past the last kept offset the readers that ask where one was removed, a here-document body and a $( ) inside double quotes, give up, so the masked status is null there rather than a guess. One offset per continuation was 22 MiB per reading of 8 MiB of `a\`. The joined line is grown once to the line's length. - Derive passes the program search's tokens to the masked-status walk for its leading-cd count (P-12's fold of statusMasked's re-read), so the line is lexed three times rather than four. - Hosts reads ssh lines to the token bound between them, each line costing at least one: tokenized line by line, each was within the bound and the whole was not. It lowers the line once for both scheme lists, and host.inetAton turns a name away on its first byte instead of letting strconv build an error value for each part. - The command is decoded once into a buffer of its encoded size (jsonscan.DecodeString) and handed back without the string() copy; each of the hook's three readers of it paid five times its length before. Measured on the hook binary with 8 MiB payloads, peak RSS before -> after: continuations 86.9 -> 43.9, $a 83.6 -> 47.8, empty lines 172.9 -> 36.4, ssh lines 177.8 -> 46.7, escaped blanks 56.3 -> 53.0, here-document 58.2 -> 57.1, short words 48.6 -> 41.8. Every case exits 0. TestRES29_ShapeOfEveryPathologicalLineIsBounded holds Derive to 40 MiB and LeadingDirectory and Hosts to 24 MiB of allocation on sixteen 8 MiB shapes; TestRES29_ContinuationsPastTheKeptOffsets pins the joins bound at N and N+1; TestRES29_StringDecodesOnce pins DecodeString to Head's and encoding/json's decoding in one allocation. Eleven mutants, one per change, turn them red. Two existing mutants are re-anchored on the moved lines.
The bound is shapeBudget over Derive's allocation per token on the 4 KB line, and the previous commit moved that allocation: a plain word is now a slice of the line rather than a copy, the leading-cd count reads the program search's tokens rather than lexing the line a fourth time, and the command is decoded once into a buffer of its size. Measured as the rows are recorded (best of three, -benchtime 20x, go1.27.2, Apple M3): BenchmarkDerive/bash_long_4k 421,153 B, 2,301 allocs -> 285,328 B, 229 BenchmarkDerive/bash_short 3,280 B, 59 allocs -> 2,656 B, 33 BenchmarkLexTokens 112,848 B, 741 allocs -> 107,376 B, 90 BenchmarkRunHook/bash_short 53,828 B, 361 allocs -> 50,612 B, 301 BenchmarkRunPost/bash_short 47,638 B, 298 allocs -> 45,728 B, 260 bytesPerToken is 285,328 over 1,068 tokens, 267.2, rounded up to 268, and maxTokens 62,601 where it was 42,473; TestRES9_BoundMatchesItsArithmetic failed on the restated row until the constant followed it. The hook and post entries' alloc_bytes_max follow their benchmarks, and internal/budget/budgets_gen.go is regenerated from the file. tokenEstimate now takes an escaped byte as part of the word: counted as a separator, 8 MiB of `\ ` -- one word -- was given a token slice for the whole bound in each reading, and with the larger bound Derive's allocation on it reached the RES-29 test's 40 MiB. The RES-10 tests that read past the bound now build their lines from maxTokens in the shape package and from 200,000 words, three times the bound, in the hook's. Two mutants anchored on the restated rows are re-anchored, and one more turns the estimate's escape back into a separator.
MaxPayloadBytes' comment said "the work is bounded where it is done", naming the token bound and the leading-cd fold. On the tree it was written for that overstated it: a line of backslash-newlines, one word of $a, or 8 MiB of `ssh a` lines each drove a hook past RES-29's 64 MiB, to 87, 84 and 178 MiB. It now names each reader's own bound -- the token bound, the continuation offsets and ssh lines held to it, plain words sliced rather than copied, one decoding per reader -- and says what kind of claim it is: measured on the sixteen line shapes the RES-29 allocation test reads, where no reader allocates more than five times the payload and the hook peaked under 58 MiB, not proved for every line.
shape.go was 1,041 code lines against the 500-line limit (Law 10), and the
earlier commits on this branch had grown it. It is now six files, each
holding one reading of a line, moved without a change to any line:
shape.go Derive, the Shape record, digests and the command field (138)
program.go the program search: programToken, pastDirectoryChange,
plainWord, commandEnd and the group tracking (235)
leading.go LeadingDirectory, MaxLeadingCd and the leading-cd run (70)
join.go joinContinuations, maxJoins and joinLines (65)
words.go word predicates: comments, redirections, assignments (149)
verb.go verb classes, test runners, may_write and output targets (408)
The one other edit is the package comment, which said that one file had to
be audited for the no-content guarantee; it has been a package of several
files for some time, and now says so.
149 mutants anchored in shape.go name the file their anchor moved to,
re-pointed by matching each anchor to the one file that holds it; the
sweep's --check finds every anchor exactly once.
masked.go was 940 code lines against the 500-line limit and parseSimple 118 against the 100-line one (Law 10). This branch changed statusMasked's signature, so the file is held to the limit here rather than left for later. parseSimple hands two self-contained steps to functions of their own, each moved without changing a line of its body: runsShellLine, the check for a shell's -c anywhere on the command, and runner, which marks a command as a test or build run in the group of runs with the same words. parseSimple is 85 code lines. The file is split by stage, moved without a change to any line: masked.go the kinds, the runner lists, statusMasked, the AST (147) masked_parse.go the grammar: lists, and-ors, pipelines, compounds (303) masked_simple.go a simple command: builtins, assignments, values (245) masked_eval.go the runner digest and the abstract run (262) 37 MS mutants name the file their anchor moved to, and the three anchored in parseSimple's moved steps still find their text.
The mutant that puts the leading-cd count back on a fourth reading of the line was red only while the escaped-blanks line sat near the RES-29 test's 40 MiB; once tokenEstimate stopped over-sizing that line's token slice it passed. The fold is a cost, not a behaviour, so it is pinned as one: TestRES29_DeriveReadsTheLineThreeTimes measures Derive's allocation past the command's decoding against one tokenizeProgram reading, on the two lines where a reading costs megabytes, and holds it to three and a half readings. Today it is 3.00 for 2.8 million escaped blanks and 2.75 for 2.8 million backslash-newlines; a fourth reading puts it at 4.00 and 3.75.
The final build's here-document case peaked at 58.0 MiB, so "under 58" in MaxPayloadBytes' comment and the RES-29 test's was not true of every run. Both now say 58 MiB or less, which is what twelve 8 MiB payload shapes measured on two builds of this tree.
RUBRIC_lld-9_word-reset.py judged its second mutant by "TestRubricLLD9_|TestTokenize", and a RUBRIC_<id> file names only the TestRubric<ID>_ tests of its own id (DEL-23). TestRubricLLD9_ alone turns it red: the reset test checks that a fresh word is quoted at -1, which a newWord that returns the zero word is not. Both mutants of the file are red under TestRubricLLD9_.
Recording `curl https://167838211/x` stores the host 10.1.2.3, since IP
literals were given one spelling, but `forget --host` still compared the
value as typed. `forget --host 167838211` matched no record and no
baseline key, printed "forgot 0 records naming 167838211" and exited 0,
and the host the user asked to forget stayed on disk. Before the change
the record held 167838211 and the same forget matched, so for these
spellings the change had made forget worse. The same split held for every
inet_aton form, expanded or upper-case IPv6, IPv4-mapped addresses, and
names typed with a port, a root dot or another case.
store.ForgetHost and baseline.ForgetFile now canonicalise the value with
host.Canonical and refuse one it rejects with store.ErrNotAHost, rather
than removing nothing and reading as done. Each stored host is compared
through host.Same: equal as stored, or equal once canonicalised. Records
and baselines written before IP literals had one spelling hold them as the
call wrote them, and comparing only the stored text would miss those.
A baseline file that does not parse is searched as bytes for both the
canonical and the typed spelling. The gap record's digest is the canonical
host's, which is the spelling the sandbox trail's events carry, so the
report's suppression of a forgotten host now holds for any spelling.
cmdForget refuses a value that names no host before opening anything, and
the summary names the canonical host ("forgot 1 record naming 10.1.2.3").
The refusal does not echo the value. The old summary repeated a value
carrying userinfo back to the terminal.
Canonicalising only the command-line value was considered and rejected.
Records from before one spelling would stop matching their own typed form,
and library callers would still digest the raw value. Storing both
spellings was also rejected, because it stores more of the most sensitive
field and changes the schema.
Tests: TestRubricLLD4_ForgetHostMatchesEverySpelling,
TestRubricLLD4_ForgetHostRefusesWhatIsNotAHost (store),
TestRubricLLD4_ForgetFileMatchesEverySpelling,
TestRubricLLD4_ForgetFileRemovesAnUnreadableFileNamingAnySpelling,
TestRubricLLD4_ForgetFileRefusesWhatIsNotAHost (baseline) and
TestRubricLLD4_ForgetCommandAsksInTheRecordedSpelling (records 10.1.2.3
through shape.Hosts, forgets 167838211). Eight mutants are added to
RUBRIC_lld-4_spellings.py. Three TRU-58 mutants and one hld-2 mutant
anchored on the old forgetLocked are re-anchored and still red.
Rubric fix list entry 30 (lld-4). CWE-178, CWE-212, CWE-532.
…cal lines RES-29 names its test TestRES29_PathologicalInputsStayBounded, a table in which each case exits 0 inside its deadline at peak RSS of at most 64 MiB. The branch had only the shape package's allocation bounds on Derive, LeadingDirectory and Hosts, and the nesting test. Nothing pinned the hook entry's exit status or its RSS, and the "58 MiB or less" figure in hook.MaxPayloadBytes' comment came from runs by hand under /usr/bin/time. The test now exists under the spec's name. It runs the hook entry in a child process against a started session, as the installed binary runs it, on RES-29's rows that the shape of a call owns: 8 MiB of short words (the first row, which the RES-9 mutant turns red), 8 MiB of `<`, a 1 MB `cd a&&` and nesting one past maxNest. It also runs every other 8 MiB line shape the shape package bounds. Each row must exit 0 with nothing printed, within the hook's 4 s hard stop and 64 MiB of peak RSS, and must leave the call recorded once with verified coverage, so a hook that stood down early cannot pass. On darwin the highest peak over three runs was 59.5 MiB (the here-document and the substitutions). With the token bound lifted, short words peaked at 703 MiB and `<` at 815 MiB. The line shapes move into test/perf/pathological, a package with no imports, so the shape package's allocation test and this one read one table. test/perf/payload could not hold it, because it imports internal/shape. The bench helpers benchSetenv, benchInstall and benchStarted take testing.TB so the test can start a session with them. RES-29's other rows are pinned beside their own entries or wait for the parts that read them; the test's comment names each one. The two "58 MiB" statements now point at the test. Mutant: RES-29_pathological.py, the token bound lifted, judged by TestRES29_PathologicalInputsStayBounded. Every other RES-29 mutant is still red with the shape test reading the shared table. Spec P-12 row RES-29. CWE-400, CWE-770.
Derive and Hosts read a shell line only up to the token bound (RES-9), and Derive's leading-cd fold stops at MaxLeadingCd (RES-10). What followed the cut was dropped and nothing said so. An ssh, scp, sftp or rsync destination named past the bound was missing from ssh_hosts, and the short list read as complete. DeriveCut and HostsCut return the same shape and hosts, plus whether the line was cut. Derive reports a cut when either reading of the line stopped at the token bound, or when the line had more leading cd steps than the fold reads. Hosts reports a cut when the ssh destination reader stopped at the bound, either inside one line or with lines left over once its budget ran out. The wire list is never cut, because the URL scan reads the whole text. Derive and Hosts keep their signatures and drop the flag. The hook writes v4 records, which have no field for it. Schema 5's shape.truncated (Archive 85.3.10) is the OR of the two flags, and P-11 serialises it. Tests: TestRES9_DeriveSaysALineWasCut, TestRES9_HostsSayAnSSHDestinationPastTheBoundWasCut (line budget at N and N+1), TestRES10_DeriveSaysTheCdFoldWasCut. There are five new mutants. The H-70 mutant is re-anchored on the renamed merge line.
TestRES10_LeadingCdFoldIsBounded asserted a 50ms wall-clock ceiling on the leading-cd fold; under -race the fold's own cost is unchanged (measured identical TotalAlloc with and without the detector) but CPU time inflates 15-30x from instrumentation, so the assertion flaked. Replaced it with a TotalAlloc bound on the same two cases, logging elapsed time instead of gating on it. A new RES-10 mutant (MaxLeadingCd raised to 20000, still under the token-bound compile ceiling) proves the allocation bound is live: it measures 613 MB against the test's 12 MiB budget, where the existing 128-valued mutant only moves allocation by a few KB. TestRES29_PathologicalInputsStayBounded asserted an unconditional 4s hard stop on the hook subprocess. That deadline is not enforced by any process in this tree today -- 85.3.10's supervisor lands at P-23 and cmdHook has no timer of its own -- so a slow hook misses the same margin the test checks, not a mechanism failing to fire. Made the wall-clock check race-aware the same way the peak-RSS check beside it already is: skip and log under -race, enforce otherwise. Verified the existing RES-29 token-bound mutant, and all RES-10 mutants, stay RED under mutdrive after the change.
statusMasked's abstract run re-reads the whole parsed line once per distinctly-worded runner group, so a line of many groups costs their product rather than their sum. A line of thousands of them -- each `make` target named once -- made that the square of the payload: 16,000 plain runners took 4.5s, and 4,000 piped to a later stage allocated 4.4 GB. Past maxRunnerGroups the kind is null, the same answer a line this function already gives up reading returns for a dozen other reasons.
authority's terminator set does not include '@' -- the whole authority has to reach host.Canonical for its userinfo refusal to see it -- or, past an unclosed '[', ']'. A command line of nothing but one scheme repeated gave it no terminator before the line's own end, and collect calls it once per match, so a line of N matches scanned the whole remaining line N times: 100 KB of `git@` took over a second here (2 minutes measured elsewhere), and an unclosed `https://[` the same way. Past authorityWindow bytes -- host.maxName plus room for a port -- no terminator has been found by the point a real host:port would already have ended, so the candidate is refused rather than truncated: truncating would hand Canonical a prefix that may have lost the '@' or ']' the wider scan existed to find. Both shapes are added to the pathological line table so the hook-level RES-29 test measures them end to end alongside the fix.
TestRubricLLD6_JoinsAreSearchedNotScanned held a 2s wall clock unconditionally, and the nesting test beside RES-29 held 50ms the same way. The join search costs no more heap than the scan it replaced -- both walk a slice already in memory -- so TotalAlloc cannot stand in for it there; the wall-clock bound now applies only outside the race detector's own CPU inflation, logged instead of enforced under it, the way cmd/rashomon's RES-29 already holds its hook deadline. The nesting test's own cost is allocation-correlated, so it moves to that gate directly.
A file written before host.Canonical folded an IP literal to its one spelling holds the uncanonicalised string -- "167838211" where a session now names "10.1.2.3" -- and compared as stored, a host the baseline already knew read as novel again. load now re-keys every host through host.Canonical and host.Shaped, merging what collapses together (earliest FirstSeenSession, latest LastSeenAtUnixMS) and dropping what is refused. declaredHosts has the same problem one level up: a run recorded before host.Canonical existed holds the raw string in its own declaration, and re-rendering that old run fed it to the baseline walk unchanged, so the same host read as a second, unknown one on every later render. It now canonicalises what it reads out of a declaration the same way.
forget --host records its digest under the CANONICAL spelling of whatever was typed, never the one a declaration happens to hold. declaredHostsFiltered and sshDeclaredFiltered canonicalised a host for the output set but asked the forgotten() check about the stored spelling first, so a declaration from before host.Canonical existed -- "167838211" where a current build writes "10.1.2.3" -- did not match the digest and escaped the check, reappearing in destinations.declared_hosts on every later render of the run that still held it, even after forgetting 10.1.2.3 reported success. sshDeclaredFiltered had the same hole one level wider: it never canonicalised an ssh host at all, on the way into the forgotten check or into the output set, so a mixed-case or otherwise legacy-spelled ssh host both escaped a forget of its canonical name and never got folded to the one spelling every current declaration uses. Both functions now canonicalise first and ask forgotten about that spelling, the same order ForgetHost itself already used when it was fixed to ask about a host in the spelling it is recorded in. Re-anchors the lld-11 declaredHosts mutant for the inserted forgotten check and adds lld-11b for the new one, proven red against a throwaway copy.
1daed7e to
a3f9a9e
Compare
0f1a864 to
2719cac
Compare
|
Thanks for the careful pass. Point by point: Blocking 1. Reproduced: 16k Blocking 2. Confirmed: 100KB of Should fix 3. The cut is already stated in store-schema.json's ssh_hosts description and lands as shape.truncated at schema 5 (DeriveCut/HostsCut). I'm not nulling the list in the meantime: record.go:134-137 defines null as 'the call named none', so that would swap a short-but-true list for a false empty one. No code change. Should fix 4. Confirmed: a baseline holding 167838211 makes 10.1.2.3 read as novel, and a pre-upgrade run re-rendered does the same one level up. load now re-keys every baseline host through host.Canonical/host.Shaped, merging what collapses together and dropping what no longer parses, and declaredHosts canonicalises an old run's own declared hosts the same way before novelty ever sees them. Should fix 5. Right, that gate didn't belong there. The join search's cost is a scan over a slice already in memory either way, so allocation can't tell it apart from the regression -- it keeps a wall-clock bound, but only outside -race, the same way cmd/rashomon's own RES-29 deadline already works. The nesting test's cost is allocation-correlated, so it moved to that gate directly. Nit 6. The baseline side clears itself out with the load-time re-key from the point above. For old run records I'm leaving the raw text alone -- no rewrite -- since records are moving to carrying full commands anyway, which will hold the same tokens regardless. No code change. Also, found while rebasing. forget --host records the canonical spelling, but declaredHostsFiltered and sshDeclaredFiltered asked forgotten() about the stored one, so a declaration in a legacy spelling, or any ssh host, came back after a successful forget. Both canonicalise first now, with a test for each case. |
lutic1
left a comment
There was a problem hiding this comment.
@k4rthiknerella, I re-reviewed the PR at 2719cac. Both blockers are fixed. Measured on linux/amd64:
| Case | Before | Now |
|---|---|---|
make tN; ×16k |
40.9 s | 137 ms |
make tN|tail; ×4k |
26.6 s | 36 ms |
400 KB git@ |
2 min 4 s | 49 ms |
900 KB https://[ |
5.8 s | 18 ms |
I also tried to get around the group cap, and the time stays linear. The stored-host migration in load is good work. The LLD6 change is fine.
Correction: our adversarial item about zoned IPv6 was wrong. plausible refuses %, so Canonical never produces a zoned host. Please ignore it.
Still blocking
inetAtonrewrites hosts made only of digits (host.go).Canonical("1234")gives0.0.4.210, and"0"gives0.0.0.0, which then reads as loopback. Your migration now runs old baseline keys and old ssh declarations through this too, so numeric ssh aliases get rewritten. Limit the folding to inputs where clients actually do it, such as URL authorities, and test numeric aliases.- Fix the timings in the description and your reply. Both say 16k runners took "4.5 s", which matches neither the 40.9 s before nor the 137 ms now.
Adversarial pass: no reply yet. Please fix or answer each item:
sshCommandHostslexes each line to the full budget, so it can use about 2× the bound (hosts.go:183-193).tokenEstimateignores quotes (tokenize.go:582). Add a quoted-blanks row topathological.Lines.startDirectoryre-lexes the line (handle.go:201-202).- RES-29 is measured only on darwin, with no Linux gate and no margin.
- Baseline errors share one message (
destinations.go:221). Result.Evictedis never shown.maxEntryBytesdoesn't cover escaping, so the baseline can get stuck permanently.clearBaselineslists files before taking the lock (main.go:1826).cutis set on a blank remainder and not set on an uncertain stop (hosts.go:189).
HostsCut/DeriveCut still have no callers. I accept "null means named none", but say in the description that a cut ssh list is still stored as complete.
Generated by Claude Code
Stacked on #61 (intake: one bounded read and one scan of every hook payload); review and merge after it.
What closes
The code-quality review's fix list named ten entries against the input side of the hook: a lexer with no token ceiling, a leading-
cdfold that re-cleaned the whole path on every step, a nesting depth nobody tested, a baseline file two concurrent renders could still clobber, and a host that could be recorded under five different spellings of the same address. This branch closes entries 10, 14, 16, 17, 28, 29, 30, 53, and 66, plus the spec rows that scheduled the same work: RES-9, RES-10, RES-11, and RES-29.Token,
cd, and nesting boundsThe lexer stops at a token ceiling (RES-9). The ceiling is derived, not guessed: it is 16 MiB divided by the bytes a line costs per token across the whole derive step, not just the lexer's own allocation, because reading a line also re-reads it for the leading-
cdfold and for the directory the hook reports. Measured on this branch after the fold was collapsed into a single scan, that cost is 268 bytes per token, which puts the bound at 62,601 tokens. An 8 MiB line of short words, which allocated over a gigabyte before the fix, now exits inside that bound.The leading-
cd DIR &&fold had no step limit and re-cleaned the growing path on every step it added, so cost grew with the square of the step count: a 480 KB payload took 10.9 seconds against the hook's own 5-second timeout. The fold now stops at 64 steps (entry 14, RES-10) and walks the path once per step instead of re-cleaning it from scratch. Past 64 steps, the hook reports the starting directory as unknown rather than guessing wrong.Nested command and arithmetic substitutions had no depth limit either, and the only thing stopping a stack overflow — a fatal error that exits 2, which blocks the tool call — was an unbounded recursive descent.
maxNestnow has a test on every construct that recurses: a quoted string inside a substitution costs two levels, so the flip between accepted and refused falls between 16 and 17 nested pairs; a substitution inside a substitution, and arithmetic inside arithmetic, flip at 33 and 34 (entry 16).status_maskedhad its own square: it re-runs the whole parsed line once per distinctly-named runner it finds, so a line of thousands of them — onemaketarget per occurrence — cost their product rather than their sum. 16,000 plain runners took 4.5 seconds; 4,000 piped to a later stage allocated 4.4 GB. Past 64 distinct runner groups the line now reads as unmasked (null) rather than re-running itself that many times.Every input bound tested at its edge, not just described
Nine more bounds across the codebase had no test at the edge where they actually matter: a bound could move by one, or the code applying it could read one byte past it, and the full suite would still pass. Entry 17 adds a table test per bound, at the value and at one past it: the store's 16 MiB line length, recap's 64 tracked sessions, spend's 100,000,000-token sanity check (checked across every field a usage record carries), spend's 64 MiB transcript line, and report's 64 Ki-rune message cap and 64 MiB transcript line. The edge tests surfaced something the three line bounds had never stated plainly: each one counts the line's own newline, because a buffered scanner needs room for the terminator to find it, so the longest line actually read is one byte short of the stated bound. The comments on those constants now say so.
A host recorded once, and a baseline that survives two renders at once
Only the ssh destination path ever checked whether a token looked like a hostname. The URL path took whatever punctuation-delimited slice came back from parsing the authority, so
curl https://api.example.com=ghp_[REDACTED]!x/recorded a "host" ofapi.example.com=ghp_[REDACTED]!x, and nothing bounded its length — a 300,000-byte string was recorded whole. A single predicate,host.Shaped, now covers both paths: a bracketed IPv6 address, or a name of at most 253 bytes built from ASCII letters, digits,-,_,., or non-ASCII letters, marks and digits, with no empty label (entry 10).That authority parse had its own square, too: its terminator set skips
@on purpose (the whole authority has to reach the userinfo refusal below) and, past an unclosed[, skips]the same way, so a line of nothing but one scheme repeated gave it no terminator before the line's own end. A command naminggit@or an unclosedhttps://[enough times scanned the whole remaining line at every occurrence — 100 KB ofgit@took over a second, and scales with the square of the line. The scan is now bounded to a host name's own length plus room for a port; past that window a candidate is refused outright rather than truncated, because a truncated authority can lose the very@or]the wider scan existed to catch.Canonicalization lower-cases and strips ports, but a real client normalizes an address further before it connects —
[2606:4700:0:0:0:0:0:1111]and2606:4700::1111are the same address to curl, and so are167838211and10.1.2.3, aninet_aton-style decimal form. Before this fix, the project's novelty baseline treated two spellings of one address as two hosts, so the second spelling always read as new. IP literals now parse once throughnet/netipand are written in a single canonical form: IPv6 compressed, lower-case, and bracketed; everyinet_atonform (decimal, octal, hex, or a short dotted form) rewritten as the equivalent dotted quad; an IPv4-mapped address written as the IPv4 address it maps. Numeric-looking names that are not actually addresses stay names. Five separate spellings of loopback are now recognized as loopback on the parsed address, pluslocalhostand names under.localhost(entry 30).forget --hostcanonicalizes its argument the same way records are canonicalized, so forgetting167838211now matches a record stored as10.1.2.3; before this fix it matched nothing and printed "forgot 0 records" while leaving the host on disk (entries 10/30, canonical spellings includingforget --host).Canonicalizing new writes still leaves every baseline and run recorded before this change on disk under the old spelling. The baseline load now re-keys every host it reads through the same canonical form, merging whatever collapses together (the earlier session's ownership, the later of the two last-seen times) and dropping whatever no longer parses as a host; the report's own read of a run's declared hosts does the same before it ever reaches the baseline. Either side left alone would have made a host the project already knew read as new again, every time an old baseline or an old run crossed paths with a new one.
The project baseline that decides novelty had its own race: rendering a report did an unlocked load-modify-save, and two concurrent renders each loaded the file, folded in their own session, and renamed their copy over the other's — in a 200-round reproduction of the race, the second rename always discarded the first render's host. The decision is now a pure fold step wrapped in a locked load-save, using the same bounded file lock the store already uses elsewhere, so a lock that cannot be acquired reports novelty as unknown rather than guessing (entry 28). The host map inside the baseline is now capped at 4,096 hosts (RES-11); past the cap, the least recently seen hosts are evicted first, and a session is never asked to drop a host it just named itself — if one session alone names more than the cap, the render reports novelty as unknown and writes nothing rather than silently dropping that session's own hosts. A baseline file is also read through a size bound now; a file larger than the bound reads as corrupt (novelty unknown) instead of being read whole, and an unchanged render no longer rewrites the file at all (entry 53).
RES-29: bounded, and gated on the right thing
RES-29 is the table that pins every pathological-input case (an 8 MiB line of short words, a 1 MB
cd a&&run, nesting one step pastmaxNest, among others) to exit 0 inside a deadline at a bounded peak RSS. Under the race detector its deadline failed on the 8 MiB cases, andTestRES10_LeadingCdFoldIsBoundedfailed its 50 ms bound, because both measured wall time. Gates here are allocated bytes and bounded work, never wall time: on the 1 MBcd a&&case the fold allocates 5,810,288 bytes with or without the detector, while its wall time goes from about 7 ms to about 93 ms.TestRES10_LeadingCdFoldIsBoundednow asserts that allocation bound and only logs the time; a mutant that raises the leading-cdceiling to 20,000 moves allocation to 613 MB against the test's 12 MiB budget.TestRES29_PathologicalInputsStayBoundedenforces its time bound on uninstrumented builds and logs it under the race detector, as its memory bound already did: nothing in this tree kills a running hook at that deadline yet, so the test measures how fast the hook is, not whether a kill fired.Two more tests in the same package had the same problem at a smaller scale. The nesting test's own cost is a few KiB regardless of how deep it goes, so it now gates on allocation directly. The here-document join search's cost is a scan over a slice already in memory either way — the search and the scan it replaced cost the same heap — so no allocation bound can tell them apart; it keeps a wall-clock bound, but only outside the race detector's own CPU inflation, the same way the hook-level deadline above already works.