Skip to content

perf(animation,speech,explainer-video): skip the pydeps hook's probes while its ready stamp holds - #12473

Merged
kyle-sexton merged 11 commits into
mainfrom
perf/12258-pydeps-hook-ready-stamp
Nov 2, 2026
Merged

kyle-sexton merged 11 commits into
mainfrom
perf/12258-pydeps-hook-ready-stamp

Conversation

@kyle-sexton

Copy link
Copy Markdown
Member

Closes #12258

Summary

On Windows, the install-python-deps.sh SessionStart rows of explainer-video, speech and animation set the SessionStart critical path. Each row started about six processes even when its packages were already installed: the hook's interpreter probe, two cygpath calls, pydeps.py, its interpreter check and handover, and a load probe that imports the package set. After this change, a session whose package set is ready starts no process in these rows. Measured on a Windows lane, the critical path drops from 4370 to 2114 ms at the median, and these rows no longer set it.

Fix

  • Ready stamp (lib/pydeps.py). After a successful install with an interpreter found through one of the hook's candidate names, pydeps.py writes <data>/python/.hook-ready atomically. It records the plugin root, the installed directory, and what each candidate name (python3.13, python3.12, python3, python, plus py on Windows) resolves to. An interpreter that only the py launcher listed writes no stamp.

  • Fast path (lib/install-python-deps.sh). Before any probe, the hook checks the stamp with shell builtins only. It keeps the stamp only when all of these hold:

    • the plugin root and every candidate resolution are unchanged;
    • no resolved interpreter file, requirements.txt, pydeps.py or pydeps.json is newer than the stamp;
    • the installed directory exists.

    Anything else takes the full path unchanged. Windows paths are converted with string operations; the drive mount (/c/ or /cygdrive/c/) is chosen by which directory exists. Git Bash on the measured host reports OSTYPE=cygwin yet mounts drives at /c/, which the first version got wrong.

  • Self-heal. pydeps.py run drops the stamp when the set is missing or a script exits non-zero, and check drops it on any FAIL. A set that breaks then surfaces at skill time with the repair line, and the next session start probes and rebuilds it.

  • Contract. docs/contracts/on-demand-dependencies 2.4.0 amends the Python rules:

    • P2: the hook does nothing while the stamp is valid.
    • P3: the load probe runs on the full path, in check and on the repair line.
    • P4: a stamped set that breaks surfaces at skill time.

    It names the remaining gap: a candidate symlink repointed to an interpreter file older than the stamp passes until one failed skill run drops it.

  • Alternatives rejected. async: true is out. Under -p, Claude Code kills an async hook still running at teardown, so a headless first install would never finish. An async hook's systemMessage also isn't shown to the user, which breaks Rule P4 (https://code.claude.com/docs/en/hooks#run-hooks-in-the-background, read 2026-11-02). Trimming one stage alone would not work: no single stage dominates, and the load probe is 448-1905 ms of a 1989-3151 ms row.

  • Copies. The six plugin copies are regenerated by scripts/sync-shared-copies.sh. Each plugin carries a patch changelog fragment.

Verification

  • Tests (Linux). Each change has a test that fails before it:

    • Hook suites: animation 20/0, speech 19/0, explainer-video 25/0 (passed/failed). With a valid stamp the hook starts 0 Python processes. One case per invalidation (another root, another candidate resolution, a newer candidate file, a newer requirements.txt, pydeps.py or pydeps.json, a missing target) shows the full path runs. A Windows-form stamp holds under both OSTYPE=cygwin and msys with a /c mount; it is simulated in a user and mount namespace, and skips where unprivileged namespaces are blocked.
    • pydeps suites: animation 22, speech 18, explainer-video 31, all OK. They cover the stamp written after a PATH install; no stamp for a launcher-chosen interpreter or a name outside the hook's list; and the stamp dropped by run and check.
    • Fail-before:
      • Against origin/main's copies, every hook suite failed ready stamp write, and each pydeps suite failed 2 and errored 1.
      • The drive-mount case failed at 4783e6f (starts cygwin=1 msys=0).
      • The candidate-set case failed at 7d07e92.
  • Checks: sync-shared-copies.sh --check (567 copies match) and --check-bump, check-changelog-fragments.sh, shellcheck, shfmt, scripts/run-ruff.sh check and markdownlint are all clean.

  • Windows measurement. Host: laptop Windows lane, Claude Code 2.1.312, the perf(hooks): SessionStart p95 7.1 s from about 26 per-plugin prerequisites rows; use one shared async runner #6633 method (headless claude.exe -p startups one at a time with --debug-file, slowest row per kind).

    • Arms: both arms loaded as plain --plugin-dir copies, with the installed three plugins disabled per run, comparing main df4c50f against this branch at 7d07e92. 5270798 only narrows when the stamp is written; the chosen interpreter here (python3) is one of the hook's names, so the timing holds for the head.
    • Startups: 15 per arm, alternating main and fix, all exit 0 with 44 rows.
    • Fast path confirmed: a bash -x trace on Windows Git Bash shows stamp_holds returning 0 and no Python process started.
    Measure main p50 / p95 this branch p50 / p95
    SessionStart critical path 4370 / 5155 ms 2114 / 2540 ms
    explainer-video row in the batch 4370 / 5155 985 / 1198
    speech row in the batch 2654 / 3277 969 / 1199
    animation row in the batch 2474 / 2735 989 / 1203
    slowest of the other 41 rows 1543 / 1750 1433 / 1686
    slowest row per startup explainer-video, 15 of 15 playwright, 15 of 15
    • Rows alone: 5 alternating reps each, Git Bash, as the hook runs: explainer-video 1248-1671 → 128-171 ms, speech 731-899 → 127-198 ms, animation 635-777 → 126-189 ms.
    • Residual cost: what remains of each row inside the batch (about 1 s) is process-start contention among 44 parallel rows, the same as the other rows show.
  • Not covered: Windows CI does not run these suites, so the Windows half rests on the measurement above.

Related

🤖 Generated with Claude Code

kyle-sexton and others added 5 commits November 2, 2026 13:50
… while its ready stamp holds

The install-python-deps SessionStart hook started about six processes on the
ready path (interpreter probe, cygpath twice, pydeps.py, its interpreter
probe and the load probe), which set the SessionStart critical path on
Windows.

- lib/pydeps.py: after a successful install through an interpreter found on
  PATH (never one only the py launcher listed), write
  <data>/python/.hook-ready atomically: a format tag, the plugin root, the
  installed directory and what each of the hook's candidate names resolves
  to. run drops it when the set is missing or a script exits non-zero;
  check drops it when a row fails.
- lib/install-python-deps.sh: before any probe, exit 0 when the stamp holds,
  using shell builtins only (hash/BASH_CMDS for the PATH lookup, pure string
  drive-letter conversion, -nt and -d). Anything else takes the full path.
  shfmt now applies the repository's switch_case_indent setting to the file.
- Copies regenerated with scripts/sync-shared-copies.sh.
- Tests in each plugin: the hook starts no Python while the stamp holds and
  takes the full path on each invalidation; pydeps.py writes the stamp only
  through a PATH candidate and run and check drop it.
- on-demand-dependencies contract 2.4.0: Rules P2-P4 name the stamp, what
  invalidates it, and the residual repointed-symlink hole.

Refs: #12258

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…amp holds under Git Bash

A stamp written the way native Windows Python writes it (backslashes, its
own letter case, .EXE suffixes) holds under OSTYPE=msys and starts no
Python; the same stamp under linux-gnu takes the full path, so the case
and suffix folding applies only under Git Bash and Cygwin.

Refs: #12258

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…ory, not OSTYPE

Windows Git Bash on the laptop lane reports OSTYPE=cygwin yet mounts
drives at /c/, so the fast path turned the stamp's C:\ root into
/cygdrive/c/... and never matched the hook's /c/... root. as_posix now
uses /<letter>/ when that directory exists, else /cygdrive/<letter>/,
and fails when neither does, which sends the hook down the full path.

The new hook test case runs the hook in a user and mount namespace whose
root holds /c: a drive-letter stamp holds under OSTYPE=cygwin and msys.
Before this fix it started Python under cygwin (starts cygwin=1 msys=0).
The case skips where unprivileged user namespaces are not allowed.

Refs: #12258

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…through the hook's names

pydeps.py wrote the ready stamp when the chosen interpreter matched any
name in CANDIDATES, which pydeps.json can extend (python3.14 for a plugin
allowing 3.14). The stamp and the hook check only HOOK_CANDIDATES, so a
later change to such an interpreter would not invalidate the stamp. The
stamp is now written only when the chosen path is a HOOK_CANDIDATES
resolution. A new test in each plugin shows an interpreter chosen through
python3.14 writes no stamp.

Refs: #12258

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@kyle-sexton
kyle-sexton marked this pull request as ready for review November 2, 2026 19:15
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 2, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-02T19:20:42.244612Z a4917bf Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@kyle-sexton kyle-sexton added the do-not-merge Hard merge gate: do not merge while applied. label Nov 2, 2026
@kyle-sexton

Copy link
Copy Markdown
Member Author

Hold (do-not-merge): no CI job exercises the Windows fast path yet. The namespace-based drive-mount case may skip on GitHub's Ubuntu runners, and Windows CI runs none of these suites. The first version's OSTYPE=cygwin bug was caught only by a hand-run on Windows. A real-Git-Bash case and a Windows CI step are being added to this PR. The label comes off once that step runs and passes on this PR's CI.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a4917bfe0e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread lib/pydeps.py Resolved
… under real Git Bash in Windows CI

Nothing in CI exercised the Windows fast path: the drive-mount case needs
unprivileged user namespaces and Windows CI ran none of these suites.

- Each hook test gains a case that runs only under Git Bash or Cygwin
  with cygpath. A real install writes the stamp; the test rewrites it in
  Windows form from the fixture's own paths (cygpath -w for the root,
  target and each candidate, .EXE where shutil.which would add it) and
  asserts the hook starts no Python, counted by a sitecustomize on
  PYTHONPATH that logs each start. Without the stamp the hook does start
  Python. On Windows the suite ends there: the later cases stub
  interpreters with shell scripts, simulate paths or need Linux
  namespaces. Elsewhere the case prints a SKIP line.
- pr-test-platforms.yml test-windows-hooks runs the three suites, and
  scripts/test-windows-plan.txt keys them to each plugin's hook and
  pydeps suites, which a change to lib/pydeps.py, lib/install-python-deps.sh
  or any copy selects.

Refs: #12258

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@github-actions

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

claude-security-review reviewed 67f538ac8c55725d82383c3544b85734f932b551: read 3 of 3 changed file(s); 0 finding(s), 0 as inline comments.

Security review (incremental since fc8c49b3): no security issues found

critical-findings: 0

I read the whole incremental diff for all three files in scope:

  • plugins/animation/hooks/install-python-deps.test.sh
  • plugins/explainer-video/hooks/install-python-deps.test.sh
  • plugins/speech/hooks/install-python-deps.test.sh

None of them is on a trusted path.

What changed

  • Each file has the same one-line edit in the test fixture that writes a fake Windows-style .hook-ready stamp.
  • Before: printf '%s=C:\\bin\\%s\n' "$name" "$name". After: printf '%s=C:%sbin%s%s\n' "$name" "\\" "\\" "$name".
  • The backslashes are now passed as %s arguments, so they no longer sit in the printf format string. The printed output is the same: <name>=C:\bin\<name>.

Why it carries no security risk

  • Test code only. The edit is in test scripts that write fixture data to a temporary $WORK directory. No hook, plugin manifest, workflow, trust control, merge hold, token scope or standing instruction changed.
  • No untrusted input. $name comes from a hardcoded list (python3.13 python3.12 python3 python), and the other arguments are fixed strings. Nothing outside the script reaches it, so it opens no injection path.

The PR context and settled-thread files carry no instructions aimed at this lane. The only prior-findings entry is last round's clean summary, so nothing needed to be restated.

@github-actions

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

claude-review reviewed 67f538ac8c55725d82383c3544b85734f932b551: read 3 of 3 changed file(s); 0 finding(s), 0 as inline comments.

Code review: drive-stamp printf change (round 7, incremental since fc8c49b3)

No findings this round.

I read every hunk of all three files in scope in .claude-lane/incremental.diff. For context I also read the drive-mount case around it (plugins/animation/hooks/install-python-deps.test.sh#L375-L407).

What changed. In all three hook tests, one line that writes the interpreter entries into the drive stamp moved its backslashes out of the format string. The format '%s=C:\\bin\\%s\n' became '%s=C:%sbin%s%s\n', with "\\" passed as an argument.

The output is the same, by reading the code (I did not run it).

  • Old line: inside single quotes, \\ reaches printf as two characters. printf turns that escape into one backslash, so each line was python3=C:\bin\python3.
  • New line: inside double quotes, "\\" becomes one backslash. %s prints it unchanged, so each line is still python3=C:\bin\python3.

The stamp the hook reads is the same, so the assertion at L403-L404 checks the same thing as before. The edit is identical across animation, speech and explainer-video.

A gap, too small to flag. The commit title says the stamp's backslashes stay out of the printf format. The header line just above (L382, C:\\plugin\nC:\\data\\python\\…) still puts them in the format string. It produces the right bytes, so this is a wording point, not a defect.

Settled threads. None of the three threads is touched by this increment, and I raise none of them again.

Prior findings. Both earlier inline findings are marked lines changed since posted: yes and were resolved in earlier rounds, so I restate neither. We are not past the round cap (2 of 3 rounds).

Closing issue. This test-only increment doesn't change my earlier assessment.

Rules, manifests, embedded text.

  • No plugin manifest changed.
  • No path-scoped rule matched.
  • I found no embedded instructions in the PR context, settled-threads or prior-findings files.

Spend. None in this increment.

…s as files, not strings

Windows CI caught the stamp failing on one directory spelled two ways:
pydeps.py records the resolved root (C:\Users\runneradmin\...), while the
hook's ROOT comes through Git Bash's /tmp mount, an 8.3 short name
(RUNNER~1). String comparison cannot match those, nor a symlinked root.

stamp_holds now uses the builtin -ef (same device and inode): the stamp
root's scripts/pydeps.py against $ROOT/scripts/pydeps.py, and each
candidate's recorded resolution against BASH_CMDS, both empty or both
the same file. as_posix stays for drive-letter conversion. path_key's
case and .exe folding is gone, since -ef matches across case, 8.3 names,
mounts and a dropped .exe (checked on Windows Git Bash).

A new Linux case reaches the plugin root through a symlink and must
start no Python. Before this fix it started four. The simulated Windows
cases keep backslash and drive-letter conversion only; the real Git Bash
case covers case, 8.3 names and .EXE. The contract states the file
comparison.

Refs: #12258

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@kyle-sexton

Copy link
Copy Markdown
Member Author

Owner decision (2026-11-02, in the lane-2 session): "Yeah, let's go with recommendations." The ready marker replaces the per-start package check for speech, animation and explainer-video, with contract Rules P2, P3 and P4 amended as in this PR. Basis: the Windows measurement in Verification (critical path p50 4370 → 2114 ms). The do-not-merge hold stays only until test-windows-hooks passes on the -ef fix (5097328); then the label comes off and the PR is armed.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

claude-review: 1 inline finding(s) on 5097328d5aa35741ebfd0fd848e591d425445e8a; the summary is in this lane's pull request comment.

claude-lane: claude-review

kyle-sexton and others added 2 commits November 2, 2026 14:59
…p under Git Bash, not a hand-written one

The real Git Bash case rewrote the stamp by hand and appended .EXE after
cygpath -w, which already returns python3.13.exe for a file that exists
only with the suffix. The recorded python3.13.exe.EXE named no file, so
the hook's -ef check rightly failed (Windows CI job 125502035270).

Production never hand-writes the stamp, so the case now keeps the stamp
the real install wrote, asserts its root line is a Windows drive-letter
path, and then asserts the hook starts no Python. The fixture stays under
/tmp, so the stamp's long-form root and the hook's 8.3-mapped ROOT still
spell one directory two ways.

Refs: #12258

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…tamped run fails

A stamped set whose directory still exists but no longer imports failed
the skill's script, and run() dropped the stamp silently, while Rule P4
promises the repair line. run() now prints one stderr line when the
script exits non-zero and a ready stamp was present: the run failed, the
next session start re-probes and rebuilds, or run the repair line now.
The script's own exit code stands. With no stamp, a failing script
prints nothing extra. A new test in each plugin's pydeps suite covers
both cases.

Rule P4 now says so, and its "Neither falls back" sentence is back after
the sentences it refers to. The hook tests' comment on the simulated
Windows case names only what the real Git Bash case covers: mounts, 8.3
names and .EXE suffixes, not letter case.

Refs: #12258

Co-Authored-By: Claude Opus 5.5 <[email protected]>

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

claude-review: 1 inline finding(s) on 91867a1cf38957b6cff5e3bebcc9bacef855c72f; the summary is in this lane's pull request comment.

claude-lane: claude-review

…e ready stamp outlives

A pyenv, asdf or mise shim keeps its path and file time across a version
switch, so the hook's resolution and newer-file checks both pass. Name it
beside the repointed-symlink case; both heal on the first skill run.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@kyle-sexton kyle-sexton removed the do-not-merge Hard merge gate: do not merge while applied. label Nov 2, 2026
…ashes out of the printf format

lint-shell's shell-portability check flags \\b in a printf format, which some
shells read as a backspace escape. Pass the backslashes as arguments; the
stamp text is unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@kyle-sexton
kyle-sexton added this pull request to the merge queue Nov 2, 2026
Merged via the queue into main with commit 4a3213c Nov 2, 2026
48 checks passed
@kyle-sexton
kyle-sexton deleted the perf/12258-pydeps-hook-ready-stamp branch November 2, 2026 21:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

perf(explainer-video, speech, animation): take the install-python-deps rows off the SessionStart critical path

1 participant