Skip to content

codex audit findings #187

Description

@mmv08

I was having a little bit of fun trying to understand more about Signalium with Codex, and it found some potential bugs. I don't have enough knowledge to see if they're significant bugs or design choices, so I thought I'd share them here:

# Signalium Audit (State Management Readiness)

## Scope

- Reviewed `packages/signalium/src` with focus on runtime reactivity, caching, scope/context behavior, and React integration.
- Re-validated each finding in this document directly against source.
- Ran unit tests: `npm run -w signalium test:unit` (17 files, 394 tests passing).
- React/browser tests were not executed in this environment (Playwright browser binaries unavailable).

## Validation Summary

| #   | Original Severity | Validation                                          | Updated Severity |
| --- | ----------------- | --------------------------------------------------- | ---------------- |
| 1   | Critical          | Confirmed                                           | Critical         |
| 2   | High              | Confirmed                                           | High             |
| 3   | High              | Confirmed                                           | High             |
| 4   | High              | Confirmed risk; partly cache-policy/design tradeoff | Medium           |

## Findings

### 1) Critical (Confirmed): `reactive()` cache is keyed only by function identity; later options and initial fallback scope are ignored

- Evidence:
  - `DERIVED_DEFINITION_MAP` is keyed only by `fn`: `packages/signalium/src/internals/core-api.ts:22`
  - Existing entry is returned without re-checking `opts`: `packages/signalium/src/internals/core-api.ts:28-31,47`
  - Fallback scope is captured at first creation and reused: `packages/signalium/src/internals/core-api.ts:33-37`
- Why this is risky:
  - `reactive(fn, optsA)` then `reactive(fn, optsB)` keeps `optsA` semantics (`equals`, `paramKey`, ids/descriptions).
  - First call site scope can become fallback for later call sites, creating cross-boundary behavior surprises.
- Recommendation:
  - Include options/scope identity in cache semantics, or avoid global memoization by raw function identity.
  - At minimum, detect conflicting options for the same `fn` and throw in dev mode.

### 2) High (Confirmed): strong `Map` for derived definitions can permanently retain function objects and captured closures

- Evidence:
  - Global strong map: `packages/signalium/src/internals/core-api.ts:22`
  - Entries are added and never deleted: `packages/signalium/src/internals/core-api.ts:44,68`
- Why this is risky:
  - Dynamic/reactive creation patterns that generate fresh function identities will accumulate entries.
  - In long-lived processes this creates unbounded retention pressure.
- Recommendation:
  - Use `WeakMap` for function-keyed cache storage when possible.
  - Document/guard against dynamic `reactive(() => ...)` creation patterns that bypass stable reuse.

### 3) High (Confirmed): falsy `paramKey` handling breaks key semantics and GC reinsertion edge cases

- Evidence:
  - `paramKey` path uses truthiness (`paramKey ? ... : args`) instead of undefined check: `packages/signalium/src/internals/contexts.ts:105-106`
  - GC reinsertion uses `if (key)` instead of `key !== undefined`: `packages/signalium/src/internals/contexts.ts:127-131`
- Why this is risky:
  - Valid keys like `0` and `''` are treated as if no key was provided.
  - Signals keyed by falsy values may not be reinserted correctly after unwatch/re-watch flows.
- Recommendation:
  - Use explicit undefined checks:
    - `paramKey !== undefined ? [paramKey] : args`
    - `if (key !== undefined) { ... }`

### 4) Medium (Confirmed Risk): parameterized reactive cache has no eviction path for non-watched entries

- Evidence:
  - New signals are always cached in scope map: `packages/signalium/src/internals/contexts.ts:109-112`
  - GC candidates are only added via unwatch transitions: `packages/signalium/src/internals/watch.ts:45-47`
- Why this is risky:
  - High-cardinality argument usage outside watcher lifecycles can grow cache indefinitely.
  - This is especially relevant for server/background code paths with many unique parameter combinations.
- Nuance:
  - Some persistence is intentional (memoization behavior), so this is both a legitimate risk and a policy decision.
- Recommendation:
  - Add cache lifecycle controls (TTL/LRU/manual invalidation), or introduce stale-entry pruning for non-watched keys.

branch diff with PoC for 1 and 3:
main...misha-phantom:signalium:main

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions