Skip to content

Unbounded sentinel thread creation on rapid permission prompts #40

@martymcenroe

Description

@martymcenroe

Summary

Every time sentinel needs to evaluate a command, do_approval() spawns a new daemon thread:

# In do_approval():
t = threading.Thread(
    target=self._sentinel_check,
    args=(pty, tool_type, tool_args),
    daemon=True
)
t.start()
return  # PTY reader resumes immediately

There is no thread pool, no concurrency limit, and no tracking of active sentinel threads. While the in_approval flag prevents sequential rapid-fire (the next permission won't trigger while the current one is being evaluated), there are edge cases:

  1. Sentinel timeout (3s) + rapid Claude output: If the Haiku API is slow and Claude somehow emits another permission prompt before in_approval clears (e.g., due to a race between the worker thread's finally block and the PTY reader's pattern match), a new thread spawns.

  2. Thread leak on exception: If _sentinel_check raises before reaching finally (theoretically impossible given the try/except/finally structure, but defensive coding matters for a security component), in_approval stays True forever and the thread is leaked.

  3. No visibility: There's no way to know how many sentinel threads are alive during a session. No logging of thread count, no max-threads guard.

Impact

  • Normal operation: Minimal — in_approval flag serializes evaluations effectively
  • Degraded API: If Haiku is slow (>3s consistently), threads accumulate waiting for timeout
  • Worst case: Thread exhaustion, though Python's threading limit is high (~thousands)

Proposed Fix

Replace ad-hoc thread creation with a single-slot worker:

# In __init__:
self._sentinel_executor = concurrent.futures.ThreadPoolExecutor(
    max_workers=1,
    thread_name_prefix="sentinel"
)

# In do_approval():
self._sentinel_executor.submit(self._sentinel_check, pty, tool_type, tool_args)

Single worker ensures:

  • At most one sentinel check in flight at any time
  • Subsequent submissions queue (won't spawn extra threads)
  • Clean shutdown via executor.shutdown() in cleanup
  • Thread naming for debugging (sentinel_0)

Affected Files

  • src/unleashed-c-21.pydo_approval() and _sentinel_check()

Context

The worker thread pattern was chosen specifically to avoid the deadlock that killed the previous integration attempt (archive/unleashed-guarded.py). It works correctly for the common case. This issue is about hardening the uncommon case — making the thread lifecycle deterministic and bounded. A ThreadPoolExecutor(max_workers=1) preserves all the benefits of the current design while adding lifecycle management for free.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingon-iceFeature/fix on hold — not in active development

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions