Skip to content

SessionEnd cleanup hook gets cancelled on exit — hooks.json ships no timeout, and Claude Code's default for SessionEnd hooks is only 1.5s #122

Description

@milojarow

Problem

On session exit, the plugin's SessionEnd hook is reported as failed on every substantive session close:

SessionEnd hook [node "${CLAUDE_PLUGIN_ROOT}/hooks/session-end-cleanup.mjs"] failed: Hook cancelled

This is not just a display artifact — the cleanup genuinely does not run. The session-scoped dedup temp files (<tmpdir>/vercel-plugin-<sessionId>-*, the claim dir / seen-skills state described in the plugin's own dedup contract) are left behind for closed sessions:

$ ls /tmp | grep vercel-plugin
vercel-plugin-1ec5a71c-...-greenfield.txt      # session closed at 22:10 — cleanup never ran
vercel-plugin-1ec5a71c-...-likely-skills.txt

Root cause

hooks/hooks.json declares the SessionEnd hook with no timeout field (current main, blob 3c6562c).

In Claude Code (verified in the 2.1.214 bundle), executeSessionEndHooks invokes the hook runner with a default timeoutMs of 1500 for SessionEnd specifically. Per hook, the budget is hook.timeout * 1000 if configured, else that 1500ms default. A hook that exceeds its budget is aborted (ABORT_ERR → "Hook cancelled").

session-end-cleanup.mjs itself is fast — standalone it exits 0 in ~50ms:

$ echo '{"session_id":"x"}' | node hooks/session-end-cleanup.mjs   # 0.047s, exit 0

But at a real session close the 1.5s window is easily consumed before the hook finishes: session teardown (notably with "tui": "fullscreen") plus sibling SessionEnd hooks run in the same instant. anthropics/claude-code#70465 (open) documents this exact class — a sub-second exit 0 SessionEnd hook cancelled alongside busy siblings. In my setup, user-configured SessionEnd hooks that declare explicit timeout values (10–30s) run to completion every close; the plugin's hook — the only SessionEnd hook without a timeout — is the only one that gets cancelled.

There's a second cost of not declaring a timeout: Claude Code derives its SessionEnd force-exit grace from the max configured timeout across all SessionEnd hooks, clamped to [1.5s, 60s]. For users whose only SessionEnd hook is this plugin's, everything collapses to the 1.5s floor.

Suggested fix

Declare a timeout on the SessionEnd hook (same one-field-per-hook shape as the shell fix proposed in #79):

     "SessionEnd": [
       {
         "hooks": [
           {
             "type": "command",
-            "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/session-end-cleanup.mjs\""
+            "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/session-end-cleanup.mjs\"",
+            "timeout": 15
           }
         ]
       }
     ]

This has no latency cost in the normal case — the hook still exits in milliseconds; the timeout only widens the abort window so teardown contention can't kill it. In the same environment, every SessionEnd hook that declares an explicit timeout completes on every close; only this one — the only one relying on the 1.5s default — gets cancelled.

timeout is a documented per-hook field in the hooks reference, and the plugin's own doctor already reasons about hook timeout risk, so this seems like the intended knob.

Environment

  • Claude Code 2.1.214, "tui": "fullscreen"
  • vercel-plugin 0.44.0 (marketplace install)
  • Linux x86_64 (Arch-based)

Related

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