Skip to content

Session lease liveness check fails on Android/Termux: dead pid returns EPERM → permanent SessionAlreadyActiveError #868

Description

@sinskl

Environment

  • Android aarch64, Termux — a supported platform with official docs (packages/coding-agent/docs/termux.md; Termux is listed in the README's Platform notes)
  • Source install, Node v24, daemon mode

Symptom

Resuming a session whose worker has been stopped (e.g. by idle eviction) always fails with:

SessionAlreadyActiveError: Session is already active in <activeSessionId>: .../<session>.jsonl

even though the worker process that owned the session lease is long dead (SIGKILLed, descriptor cleaned up, socket gone).

Root cause

Three layers combine to make the lease liveness check permanently report "alive":

  1. SessionLease (src/core/session-lease.ts) is released only on graceful shutdown (release()). A worker killed with SIGKILL (e.g. idle eviction that did not stop cleanly, later adopted with SIGKILL on daemon restart) leaves ~/.prime/agent/session-leases/<hash>.lock/owner.json behind.

  2. isProcessAlive (session-lease.ts:104) uses process.kill(pid, 0). On Android/Termux a dead pid that has been reused by another (SELinux-protected) process returns EPERM, not ESRCH, and the code conservatively treats EPERM as alive:

    function isProcessAlive(pid: number): boolean {
        try { process.kill(pid, 0); return true; }
        catch (error) { return (error as NodeJS.ErrnoException).code === "EPERM"; }
    }
  3. The second guard isLeaseOwnerAlive (session-lease.ts:177) compares processStartId read from /proc/<pid>/stat. On Termux the reused pid's /proc/<pid>/stat is invisible (ENOENT), the ps fallback also returns nothing, so getProcessStartId returns undefined — and undefined is treated as alive:

    const currentStartId = getProcessStartId(owner.pid);
    return currentStartId === undefined || currentStartId === owner.processStartId;

Result: acquireSessionLease never reaches reclaimStaleLease and the session can never be resumed without manually deleting the lease directory.

Verified on device

pid 99999          (never existed)        -> kill(0) ESRCH      /proc/<pid>/stat ENOENT
pid 26617          (dead, pid reused)     -> kill(0) EPERM ❌   /proc/<pid>/stat ENOENT ✅
pid 29551/30447    (alive, same uid)      -> kill(0) OK         /proc/<pid>/stat OK

So /proc/<pid>/stat's ENOENT semantics are reliable for same-uid processes where kill(0) EPERM is not.

Suggested fixes

Minimal (3 lines): in isProcessAlive, on EPERM also check /proc/<pid>/stat; if it is ENOENT, treat the process as dead (falling back to the current conservative behavior only when the proc entry is readable).

Robust (recommended): make the lease an ownership lock held with flock/fcntl (e.g. keep the lock file open for the lease duration). The kernel guarantees the lock is released when the owning process dies — including SIGKILL — with no dependency on pid semantics, /proc visibility, or kill(0) behavior. Verified working on Termux: a SIGKILLed flock holder's lock is released immediately and a new acquirer succeeds.

Related issues

Same stale-session-lease failure family, different platform-specific fault point:

A robust fix that works everywhere would be to hold the lease as an flock/fcntl ownership lock (kernel releases it automatically when the owning process dies, independent of pid semantics and /proc visibility).

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