Skip to content

plugin: the daily lock prune deletes the lock of a session open for more than two days, and its hooks run in parallel again #210

Closed

Description

What happens

Each session's hooks share one lock file, <root>/locks/<host>/.<session>.lock (#156). The file is created at the session's first hook. After that, taking the lock only opens the file and flocks it. Nothing is written, so the file's mtime stays at the session's first hook.

Once a day, _prune removes lock files whose mtime is more than IDLE_S (2 days) old. It runs inside run, while the hook holds its own lock:

https://github.com/microsoft/TokenEconomizer/blob/81e32e5/tokeneconomizer/plugin/hookqueue.py#L114-L146

So once a session has been open for more than 2 days (a long Claude Code desktop session, or a resumed one), its lock file looks idle. The prune then deletes it while a hook holds it. On Linux and macOS the next hook of that session opens a new file at the same path. It locks the new file right away and runs beside the hook that still holds the old one. That is the parallel-hooks race from #142: both read the session document, and the last writer drops the other's calls from the loop guard's turn. On Windows the delete fails while the file is open, so this only affects POSIX.

Repro (on main at 81e32e5, Linux)

import os, sys, tempfile, time
from types import SimpleNamespace
from tokeneconomizer.plugin import hookqueue
from tokeneconomizer.plugin.store import Store

root = tempfile.mkdtemp()
base = os.path.join(root, "locks", "claude")
os.makedirs(base)
lock = os.path.join(base, ".s.lock")
open(lock, "a").close()                       # the session's first hook, three days ago
old = time.time() - 3 * 86400
os.utime(lock, (old, old))

ctrl = SimpleNamespace(ctx=SimpleNamespace(root=root), host=SimpleNamespace(name="claude"),
                       sid="s", p={}, cfg=SimpleNamespace(repomap="off"))
seen = {}
def handler():
    # a parallel hook of the same session arrives meanwhile
    with Store(base).locked("s", timeout=0.3) as got:
        seen["second hook got the lock"] = got
    return {}
hookqueue.run(ctrl, "PostToolUse", handler)
print(seen)
{'second hook got the lock': True}

Expected

False. The first hook still holds the session's lock. A lock that is in use should not look idle to the prune.

I have a fix with a regression test and will open a PR.

Activity

  1. added a commit that references this issue on Nov 11, 2026
    4b8f34e
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