| 1 |
High |
lru() factory validation |
lru("10"), lru(true), lru(2.5), lru(Infinity), lru(null), lru(false), lru("") all pass validation but silently disable eviction. this.size === this.max uses strict equality, so a non-integer max never evicts. |
lru("10") → size 15 after 15 sets. lru(2.5) → size 5 after 5 sets. lru(Infinity) → size 5 after 5 sets. lru("") accepted (isNaN("") is false). |
| 2 |
High |
set() on expired key |
With resetTTL=false, set() on an expired key leaves the item dead but occupying a slot. has() false, get() undefined, but size unchanged. With resetTTL=true it resurrects correctly — inconsistent. |
set("k","v"), wait 80ms, set("k","v2") → has(k)=false, get(k)=undefined, size=1. |
| 3 |
High |
TTL semantics across read methods |
values(), entries(), forEach(), toJSON() return expired items, while get()/has() treat them as gone. Inconsistent TTL enforcement. |
Expired item: values()=["v"], entries()=[["k","v"]], forEach sees it, toJSON includes it. get(k)=undefined. |
| 4 |
High |
forEach() mutation truncates iteration |
Deleting the current item during forEach() truncates the loop — subsequent items are never visited. |
forEach((v,k)=>{seen.push(k); c.delete(k);}) on 4 items → seen=["a"] (only first visited). |
| 5 |
Medium |
get() stats side effect |
get() on an expired item increments BOTH deletes and misses. A read miss triggers a delete. |
After expired get(k): stats={hits:0, misses:1, sets:1, deletes:1}. |
| 6 |
Medium |
cleanup() vs evict() onEvict |
cleanup() removes expired items but does NOT fire onEvict; evict() does. Inconsistent eviction notification. |
Test confirms onEvict not called during cleanup(). |
| 7 |
Medium |
setWithEvicted() on expired key at max |
On an expired key at max capacity, setWithEvicted() returns null evicted and leaves a dead item in place — the expired item is never reclaimed. |
set("a",1) at max=1, wait, setWithEvicted("a",2) → evicted=null, get(a)=undefined. |
| 8 |
Medium |
Batch method side effects |
getMany() deletes expired items (size drops to 0), but hasAll()/hasAny() do not (size unchanged). Inconsistent. |
getMany(["a","b"]) on expired → {}, size=0. hasAll → false, size=2. |
| 9 |
Medium |
Key coercion collisions |
set(1) and set("1") collide to the same slot. Same for set(true)/set("true") and set(-0)/set(0). Object keys coerce to "[object Object]". |
set(1,"n"); set("1","s") → size=1, get(1)="s". set(true)/set("true") → size=2, get(true)="S". |
| 10 |
Medium |
values(null) / entries(null) crash |
Passing null as the keys argument throws TypeError: Cannot read properties of null (reading 'length'). |
values(null) → TypeError. entries(null) → TypeError. |
| 11 |
Medium |
Batch methods crash on null/undefined |
getMany(null), hasAll(null), hasAny(undefined) throw TypeError: Cannot read properties of null/undefined (reading 'length'). |
getMany(null) → TypeError. hasAny(undefined) → TypeError. |
| 12 |
Low |
Constructor validation |
new LRU(-1) works and behaves as unlimited. Constructor does not validate params (documented, but class is public API). |
new LRU(-1) → size 2 after 2 sets, no eviction. |
| 13 |
Low |
sizeByTTL/keysByTTL/valuesByTTL noTTL semantics |
Items with expiry=0 are counted as noTTL even when ttl>0. |
With ttl=100, item with expiry=0 → sizeByTTL={valid:1, expired:0, noTTL:1}. |
| 14 |
Low |
peek() on expired item |
peek() returns expired value (documented as no TTL check). By design, but inconsistent with get(). |
Expired item: peek(k)="v". |
| 15 |
Low |
clear()/delete() don't fire onEvict |
Only evict() fires onEvict. Deleting or clearing items silently skips the callback. |
onEvict fired for [] after delete+clear; only evict() triggered it. |
| 16 |
Low |
setWithEvicted() double-notification |
Returns the evicted item AND fires onEvict for the same eviction — caller gets it twice. |
setWithEvicted("c",3) at max → returned {key:"a",...} AND onEvict fired once. |
| 17 |
Low |
String keys argument treated as char list |
values("abc")/entries("abc") iterate the string as single-char keys. |
values("abc") → [1,2,3] for keys a,b,c. |
| 18 |
Low |
Non-array input silently ignored |
getMany(5) returns {} silently instead of throwing or validating. |
getMany(5) → {}. |
Summary
The tiny-lru cache has multiple edge-case inconsistencies in TTL handling, factory validation, iteration safety, and key coercion. All 149 existing tests pass, but these scenarios are untested and produce incorrect behavior or crashes. This issue consolidates all findings into a single actionable table.
Reproduction
All findings reproduced with
nodeprobes againstsrc/lru.js. See evidence column per finding.Findings
lru()factory validationlru("10"),lru(true),lru(2.5),lru(Infinity),lru(null),lru(false),lru("")all pass validation but silently disable eviction.this.size === this.maxuses strict equality, so a non-integer max never evicts.lru("10")→ size 15 after 15 sets.lru(2.5)→ size 5 after 5 sets.lru(Infinity)→ size 5 after 5 sets.lru("")accepted (isNaN("")is false).set()on expired keyresetTTL=false,set()on an expired key leaves the item dead but occupying a slot.has()false,get()undefined, butsizeunchanged. WithresetTTL=trueit resurrects correctly — inconsistent.set("k","v"), wait 80ms,set("k","v2")→has(k)=false,get(k)=undefined,size=1.values(),entries(),forEach(),toJSON()return expired items, whileget()/has()treat them as gone. Inconsistent TTL enforcement.values()=["v"],entries()=[["k","v"]],forEachsees it,toJSONincludes it.get(k)=undefined.forEach()mutation truncates iterationforEach()truncates the loop — subsequent items are never visited.forEach((v,k)=>{seen.push(k); c.delete(k);})on 4 items →seen=["a"](only first visited).get()stats side effectget()on an expired item increments BOTHdeletesandmisses. A read miss triggers a delete.get(k):stats={hits:0, misses:1, sets:1, deletes:1}.cleanup()vsevict()onEvictcleanup()removes expired items but does NOT fireonEvict;evict()does. Inconsistent eviction notification.onEvictnot called duringcleanup().setWithEvicted()on expired key at maxsetWithEvicted()returnsnullevicted and leaves a dead item in place — the expired item is never reclaimed.set("a",1)at max=1, wait,setWithEvicted("a",2)→evicted=null,get(a)=undefined.getMany()deletes expired items (size drops to 0), buthasAll()/hasAny()do not (size unchanged). Inconsistent.getMany(["a","b"])on expired →{},size=0.hasAll→ false,size=2.set(1)andset("1")collide to the same slot. Same forset(true)/set("true")andset(-0)/set(0). Object keys coerce to"[object Object]".set(1,"n"); set("1","s")→size=1,get(1)="s".set(true)/set("true")→size=2,get(true)="S".values(null)/entries(null)crashnullas the keys argument throwsTypeError: Cannot read properties of null (reading 'length').values(null)→TypeError.entries(null)→TypeError.getMany(null),hasAll(null),hasAny(undefined)throwTypeError: Cannot read properties of null/undefined (reading 'length').getMany(null)→TypeError.hasAny(undefined)→TypeError.new LRU(-1)works and behaves as unlimited. Constructor does not validate params (documented, but class is public API).new LRU(-1)→ size 2 after 2 sets, no eviction.sizeByTTL/keysByTTL/valuesByTTLnoTTL semanticsexpiry=0are counted asnoTTLeven whenttl>0.ttl=100, item withexpiry=0→sizeByTTL={valid:1, expired:0, noTTL:1}.peek()on expired itempeek()returns expired value (documented as no TTL check). By design, but inconsistent withget().peek(k)="v".clear()/delete()don't fireonEvictevict()firesonEvict. Deleting or clearing items silently skips the callback.onEvictfired for[]after delete+clear; onlyevict()triggered it.setWithEvicted()double-notificationonEvictfor the same eviction — caller gets it twice.setWithEvicted("c",3)at max → returned{key:"a",...}ANDonEvictfired once.values("abc")/entries("abc")iterate the string as single-char keys.values("abc")→[1,2,3]for keys a,b,c.getMany(5)returns{}silently instead of throwing or validating.getMany(5)→{}.Expected vs Actual
set()on an expired key either reclaims it or refreshes it; factory validation rejects non-integer max values; eviction notification is consistent; iteration is mutation-safe; keys are validated.values()/entries()/forEach()/toJSON()leak expired items;set()leaves dead items occupying slots;lru()accepts non-integer max values that disable eviction;cleanup()skipsonEvict;forEach()truncates on mutation;values(null)/entries(null)/getMany(null)crash; numeric/string keys collide.Root Cause Analysis
lru()factory usesisNaN(max)which returns false for numeric-coercible strings ("10"), booleans (true), floats (2.5),Infinity,null, andfalse. The eviction guardthis.size === this.maxthen never triggers for non-integer values.set()andsetWithEvicted()checkitem !== undefinedto decide update-vs-insert, but never check#isExpired(item). An expired item is treated as a live update, so its stale expiry is preserved.forEach()iterates the linked list by followingx.next. Deleting the current item nullifies itsnextpointer, so the loop terminates early.get()deletes expired items (incrementingdeletes), whilecleanup()and the batchhas*methods do not — inconsistent TTL enforcement paths.itemsis a plain object keyed bykey, so JS coerces all keys to strings.1and"1",trueand"true",-0and0all collide.values()/entries()/getMany()/hasAll()/hasAny()assumekeysis an array and access.lengthwithout validation.Testing Strategy
set()on expired key with bothresetTTLvalues, read-method TTL enforcement,forEach()mutation,get()stats on expired,cleanup()onEvict,setWithEvicted()on expired at max, batch method side effects, key coercion, null/undefined keys argument, string keys argument.expiry=0withttl>0, negative constructor max,lru(""), symbol keys,__proto__key,values(null).Security Considerations
values()/entries()/forEach()/toJSON()after TTL expiry. The__proto__key is handled safely (null-prototypeitemsprevents prototype pollution — verifiedObject.prototype.pollutedis undefined).Audit Findings (for Issue #487)
src/lru.js—lru()factory at lines 659-673 usesisNaN(max)which accepts numeric-coercible strings, booleans, floats,Infinity,null, andfalse. The eviction guardthis.size === this.max(lines 296, 345) then never triggers for non-integer max values.src/lru.js—set()(lines 333-369) andsetWithEvicted()(lines 285-324) checkitem !== undefinedto decide update-vs-insert but never check#isExpired(item). An expired item is treated as a live update, preserving its stale expiry.src/lru.js—forEach()(lines 407-413) iterates by followingx.next; deleting the current item nullifiesnext, truncating the loop.src/lru.js—get()(lines 184-201) deletes expired items and incrementsdeleteson a miss.cleanup()(lines 469-497) removes expired items without firing#onEvict. Inconsistent TTL enforcement and eviction notification.src/lru.js—values()(lines 379-396),entries()(lines 90-103),getMany()(lines 421-429),hasAll()(lines 437-445),hasAny()(lines 453-461) assumekeysis an array and access.lengthwithout validation —null/undefinedcrash.src/lru.js—itemsis a plain object keyed bykey(line 23), so JS coerces all keys to strings.1/"1",true/"true",-0/0collide.tests/unit/lru.test.js— 149 tests pass but none cover expired-keyset(), non-integer factory max, read-method TTL enforcement,forEach()mutation, key coercion, or null/undefined keys argument.Fix Steps
lru(), replaceisNaN(max)with!Number.isInteger(max) || max < 0. Apply the same tottl.set()/setWithEvicted()— In the update branch, check#isExpired(item)first. If expired, treat as a fresh insert (reclaim the slot) rather than an update.values(),entries(),forEach(),toJSON()skip expired items, consistent withget()/has().forEach()mutation-safe — Capture the next pointer before invoking the callback so deleting the current item doesn't truncate iteration.get()stats — Do not incrementdeleteswhenget()removes an expired item on a miss.onEvictincleanup()— Call#onEvictfor each expired item removed, consistent withevict().values(),entries(),getMany(),hasAll(),hasAny()against null/undefined/non-array input.tests/unit/lru.test.js.npm run testandnpm run coverageto confirm 100% line coverage and no regressions.