Skip to content

Bad --settings path: node still writes the session transcript, perry writes none (E36_badsettings_p) #9575

Closed

Description

@proggeramlug

From the full verification sweep at ed99c35cd (case E36_badsettings_p: -p hi with a malformed --settings file):

node rc=[1] | perry rc=[1]
ISSUES: HOME-PATHS-ONLY-IN-NODE: ['.claude/projects/<CWDKEY>/<UUID>.jsonl']

Exit codes and output match — but on the settings-error path node still records the session transcript and perry records nothing. Same family as the #9421 root cause (an error path where the transcript machinery is skipped or its write dies before the directory exists), but on the settings-error branch rather than --bare. Since #9491 made fs errors throw, check first whether perry's writer runs and throws here (swallowed higher up) or is never reached.

Also from the same sweep, one persisting item to re-check against #9487's closure: B60_install_badtarget still exits 0 under perry where node exits 1 — either the fix missed the install subpath or a train dropped a hunk; whoever takes this issue can verify both in one session.

Repro: harness case E36_badsettings_p in /root/claude-ccstress/; tip binary /root/claude-roots/app_c08.

Activity

  1. proggeramlug commented on Sep 4, 2026

    @proggeramlug
    ContributorAuthor

    I’m working on this now. I reproduced E36_badsettings_p three times against the referenced app_c08 binary: both engines exit 1 every run, while only Node writes .claude/projects//.jsonl. I’ll trace whether Perry skips the write or loses it on the error path, add a minimized regression where possible, and re-check B60 separately.

  2. proggeramlug commented on Sep 4, 2026

    @proggeramlug
    ContributorAuthor

    E36_badsettings_p — fixed on current main

    Re-ran the exact case against a freshly built bundle at current main (0e3ce3812), with /root/claude-roots/app_c08 (8d27f7b96, the tip binary this issue cites) as a paired control in the same environment. The main bundle carries a single runtime stamp (PERRY_RUNTIME_BUILD_STAMP_V1|…|build=git:0e3ce3812…) — my first attempt was a Frankenstein (the worktree's target/ was a symlink into a tree checked out at the old commit, so --enable-wasm-runtime rebuilt the old runtime under a new compiler); that build was discarded and redone in an isolated worktree.

    Harness, REPS=3, three independent rounds (9 runs per engine):

    binary verdict
    app_c08 (8d27f7b96) FAIL … HOME-PATHS-ONLY-IN-NODE: ['.claude/projects/<CWDKEY>/<UUID>.jsonl']
    current main (0e3ce3812) PASS, PASS, PASS

    Direct probes (fresh HOME, -p hi, stdin closed) — is the transcript written?

    settings file app_c08 main
    none, or an empty .claude/ dir, or an unrelated file in .claude/ ✓ ✓
    .claude/settings.json = {} ✗ 0/10 ✓ 10/10
    .claude/settings.json = malformed / 5 / null / {"model":"x"} / whitespace ✗ ✓
    .claude/settings.json, 0 bytes ✗ (6/7) ✓
    .claude/settings.local.json = {} ✗ ✓
    ~/.claude/settings.json = {} (user level, no project settings) ✗ ✓

    The discriminator is narrower than "malformed JSON"

    It is not JSON parsing and it is not project-specific: it is a settings file existing at all, at either level, whatever the contents — a 0-byte file triggers it too. What those cases share is that claude-code starts its settings-file watcher; cc's own debug log (CLAUDE_CODE_DEBUG_LOGS_DIR) makes it exact — Watching for changes in setting files … appears in precisely the failing configurations and never in the passing ones. An empty .claude/ directory does not start it, and the transcript survives.

    What was actually going wrong

    cc's shutdown (WK()) awaits its whole cleanup registry — await Promise.all(Array.from(z11).map((q) => q())) — and both the session-transcript flush (eq(async () => { await writer.flush(); … })) and the run_state_at_shutdown diagnostic are entries in it.

    On app_c08 with a settings file present, that chain did not complete. CLAUDE_CODE_DIAGNOSTICS_FILE shows the earlier exited cleanup firing but run_state_at_shutdown missing, and strace shows the transcript opened once O_RDONLY (ENOENT) and then never for writing — exit_group(1) lands 1.6 ms after the final stdout write, with no O_WRONLY|O_CREAT|O_APPEND at all.

    That it is the cleanup chain and not the 100 ms flush timer losing a race is confirmable from the outside: give cc a SessionEnd hook, and the hook's subprocess keeps the process alive long enough for the timer to fire — the transcript reappears while run_state_at_shutdown is still missing. So the flush cleanup was not running; the timer was the only thing that ever wrote it.

    On current main both symptoms clear together: run_state_at_shutdown is present in all four settings placements, and the transcript append lands inside shutdown — 8.7 ms after the final output and 1.3 ms before exit_group. That is the chain completing, not a timing shift.

    Attribution

    Not bisected commit-by-commit (each point is a ~35 min bundle compile on a contended host), but the syscall evidence points at one commit. The failing configuration is exactly "cc creates an fs.watch-backed (chokidar) settings watcher", whose teardown W() is a synchronous entry in that same cleanup registry, and the watcher backend demonstrably changed under it:

    • app_c08 (8d27f7b96), settings present: inotify_add_watch = 0 — the old ref'd 25 ms setInterval tree re-walk.
    • main (0e3ce3812), settings present: inotify_add_watch = 4, on <cwd>/.claude and <cwd>/.claude/settings.json. Settings absent: 0, on both binaries.

    fd17689ca6 (#9591, changelog.d/9613-fs-watch-os-events.md — "fs.watch uses OS change notifications, not a 25 ms tree re-walk … liveness moved from the ref'd timer to a runtime has-active slot") is the only commit in 8d27f7b96..main that touches fs.watch, and it is the change those syscalls show. So: incidentally fixed there, on the balance of the evidence rather than by bisect.

    Closing — the transcript is written in every settings shape on current main.

    B60_install_badtarget — still failing, and it is a different bug

    Re-checked as asked; unchanged on current main: node rc=1, perry rc=0, perry's stdout one byte short (the trailing \n).

    Root cause found, and it is not the install path, so #9487's closure did not miss a hunk. cc's install subcommand is

    let { unmount: O } = await eB(el(wMA, { onDone: (w, $) => { O(), q(w, $) }, … }))

    — the callback closes over O, the binding introduced by the very declarator whose initializer creates it. Under perry that callback throws ReferenceError: O is not defined (cc logs uncaught_exception, 3/3 runs), and the uncaught throw is what loses the last write and the exit code.

    Minimized and reproducing on current main:

    const { u: O } = await mk({ cb: () => { O(); } });   // perry: ReferenceError: O is not defined

    Every shape where the closure is built inside the declarator's initializer fails — object destructuring, array destructuring, plain binding, with and without await, let and const; a closure created after the declaration is fine.

    Filed as #9718 with the minimized repro and the shape matrix; a fix is in progress there. Not holding this issue open for it.

    Reproduction artifacts

    On the build host: harness copy /root/cc9575/ (harness.py, compare.py, cases_9575.jsonl, probe*.sh), main bundle /root/cc9575/app_main, isolated worktrees /root/cc9575/wt-main and /root/cc9575/wt-old.

  3. proggeramlug commented on Sep 4, 2026

    @proggeramlug
    ContributorAuthor

    Closing: E36_badsettings_p passes on current main (3 rounds x REPS=3, and 10/10 on the direct probe), with app_c08 as a paired control failing 0/10 in the same environment. B60 is tracked separately as #9718.

  4. proggeramlug commented on Sep 4, 2026

    @proggeramlug
    ContributorAuthor

    Follow-up: B60_install_badtarget is now fixed too, by #9720 (the closure/initializer binding bug filed as #9718).

    Rebuilt cli_2.1.112.js with the patched compiler and re-ran the harness for both cases this issue carried:

    PASS  B60_install_badtarget
    PASS  E36_badsettings_p
    
    === 2/2 pass
    

    claude install not-a-version now exits 1 with 161 bytes of stdout and no uncaught_exception, matching node exactly (unpatched main: rc 0, 160 bytes, one ReferenceError: O is not defined).

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