Skip to content

fix(cli): exit 1 instead of aborting on uncaught filesystem errors - #179

Merged
taylorg009 merged 5 commits into
mainfrom
fix-uncaught-fs-error
Oct 19, 2026
Merged

taylorg009 merged 5 commits into
mainfrom
fix-uncaught-fs-error

Conversation

@taylorg009

@taylorg009 taylorg009 commented Oct 3, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

On bin-v0.8.3, cgraph seam fuse --seam ... --graph ... --out <dir whose parent is read-only> aborts with a core dump:

terminate called after throwing an instance of 'std::filesystem::__cxx11::filesystem_error'
  what():  filesystem error: cannot create directories: Permission denied [/tmp/.../read-only/out]
exit 134

#173 made main catch cgraph::FileWriteError, but std::filesystem::create_directories (and any other exception) still escaped. graphd, cgraph-client and cgraph-mcp had no handler at all.

Fix

  • atomic_write: new create_output_directories(dir). It throws FileWriteError with the same message style as fix(io): fail loud when an output file cannot be written whole #173: failed to write <dir>: cannot create directory (Permission denied). It is used by the CLI output paths (build, seam gen/discover/fuse), write_exports, write_semantic_stat_index and write_semantic_cache. The daemon callers of the last two already log FileWriteError as non-fatal (persist_stat_index, the semantic cache write in daemon_server.cpp), so a directory failure there no longer kills graphd either.
  • cli/main: a single std::exception handler prints cgraph: <what> and exits 1. That covers FileWriteError, filesystem_error (its what() names the path) and anything else.
  • graphd, cgraph-client, cgraph-mcp: each gets the same last-resort handler (<tool>: <what>, exit 1).
  • mcp_server: if the forwarder throws, only that request fails, with JSON-RPC -32603 carrying the message. The stdio loop keeps running. Before this change only graph_change_context was caught.

Graph output is unchanged and kIndexVersionKey is not bumped.

Evidence

The new cli_main_test case was run against the released binary (bin-v0.8.3, revision 269048a; there is no src/ or tests/ diff from 269048a to main):

FAIL: seam fuse into a read-only parent exits 1 (got 134): terminate called after throwing an instance of 'std::filesystem::__cxx11::filesystem_error'
FAIL: one-shot build into a read-only parent exits 1 (got 134): ...
exit=1

The same test against this branch prints cli_main_test: ok (exit 0).

mcp_server_test with the mcp_server.cpp hunk reverted aborts with terminate called ... cannot create directories: Permission denied [/ro/daemon-dir] (exit 134). With the fix it exits 0.

Manual end-to-end runs, released binary vs this branch:

command bin-v0.8.3 this branch
graphd --root /tmp --idle-timeout abc 134, terminate ... stoll 1, graphd: stoll
cgraph-client status from a deleted cwd 134 1, cgraph-client: filesystem error: cannot get current path: No such file or directory
cgraph-mcp from a deleted cwd 134 1, cgraph-mcp: filesystem error: cannot get current path: ...

Full suite: ctest --test-dir build/default -j6 passed 95 of 96. The one failure is cgraph_file_watcher_test, which is a known local failure on this host.

Review round 1

1. graphd exception safety (src/engine/daemon_server.cpp)

  • If the serve loop, the initial-build thread or the enrichment worker throws, the exception is now logged with its type (graphd: <where> failed: <type>: <what>). The daemon then stops and run_daemon_server returns 1.
  • A ScopeExit guard joins both workers, closes the listen socket and runs cleanup_daemon_endpoint on every exit path. Nothing unwinds past a joinable std::thread any more.
  • Each connection is closed by its own guard.
  • The exit-time incremental persist is skipped after a failure, because the index may be half-updated.
  • Most of this file's diff is re-indentation from wrapping the loop in try. Review with git diff -w.
  • Test, daemon_server_test: the root's parent loses search permission, so weakly_canonical(root) throws filesystem_error. This is tested twice: from an update (serve loop) and from the initial build (build thread). Before the fix the test aborts (exit 134, terminate called ... status: Permission denied [/tmp/cgraph_daemon_fatal_test/proj]). After the fix it exits 0, and both cases return 1 with the endpoint removed.

2. cgraph-mcp malformed lines (src/mcp/mcp_server.cpp)

  • handle_mcp_request catches nlohmann::json::exception and answers -32600 with id: null. It does this per request, so the stdio loop keeps serving.
  • Test, new mcp_main_test: it pipes 5, [1] and {"jsonrpc":"2.0","id":3,"method":7} into the real cgraph-mcp, followed by tools/list.
    • Before the fix: cgraph-mcp: ... type_error.306 ... cannot use value() with number, exit 1, and no responses.
    • After the fix: four responses, and tools/list is still answered.
  • mcp_server_test also checks these shapes, which abort before the fix.

3. Numeric flags (new cgraph/cli_support)

  • One shared set of helpers replaces every stoi/stoll/stod/stol in src/cli/main.cpp and graphd's --idle-timeout: parse_integer<T>, integer_flag<T> and number_flag. They use from_chars for integers and strtod for numbers, because macOS libc++ lacks floating-point from_chars.
  • A bad value exits 2 with, for example, cgraph: --depth expects an integer, got 'abc' or graphd: --idle-timeout expects an integer, got 'abc'.
  • stats --since reuses parse_integer, so 5xh is now rejected; stol used to read it as 5.
  • change-context keeps its JSON error output and now exits 2 for a bad flag.
  • Test: cli_main_test runs report modules --depth abc.
    • bin-v0.8.3: exit 134 (what(): stoi).
    • Round 0 of this PR: exit 1 (cgraph: stoi).
    • Now: exit 2 with the message above.
  • cli_support_test covers the helpers.
  • This supersedes the graphd --idle-timeout abc row in the table above: it now exits 2 with the usage message.

4. Last-resort handlers

5. atomic_write_test

  • create_output_directories is now covered for an empty path (no-op), an existing directory, nested creation, and a read-only parent. The read-only case must throw FileWriteError with exactly failed to write <dir>: cannot create directory (Permission denied).

Verification

  • Full suite: 97 of 98 pass. The one failure is cgraph_file_watcher_test, which is a known local failure.
  • origin/main is merged in (already up to date at d526ca8).

Review round 2

  1. Blocker, macOS test failure (tests/smoke/cli_support_test.cpp): the filesystem_error case now checks only for filesystem_error: and [/x]. libc++ names the type std::__1::__fs::filesystem::filesystem_error. The std::out_of_range: check is unchanged. I could only verify on Linux (cli_support_test: ok); the macOS fix is verified by CI only.
  2. MCP id echo (src/mcp/mcp_server.cpp): an Invalid Request now returns the request's id when the request is an object and its id is a string, number or null. Otherwise the id is null.
    • mcp_main_test expects null, null and 3 for 5, [1] and {"jsonrpc":"2.0","id":3,"method":7}.
    • mcp_server_test also covers a string id and an object id (which gets null).
    • With the previous mcp_server.cpp the test fails: FAIL: malformed line 3 answered with Invalid Request, id 3: {..."id":null...}. With the fix it prints mcp_main_test: ok.
  3. MSVC build (src/engine/cli_support.cpp): <cxxabi.h> and __cxa_demangle are now guarded by #if !defined(_MSC_VER). MSVC uses typeid(...).name(), which is already readable there.

Note on number parsing: from_chars rejects a leading + that stoi accepted. cgraph report modules --depth +5 now prints cgraph: --depth expects an integer, got '+5' and exits 2.

Verification: the full suite passes 97 of 98. The one failure is cgraph_file_watcher_test, which is a known local failure. origin/main is merged in (already up to date).

🤖 Generated with Claude Code

`cgraph seam fuse --out <dir under a read-only parent>` (and the one-shot
build) aborted with exit 134: create_directories threw an uncaught
std::filesystem::filesystem_error, and main only caught FileWriteError.

- atomic_write: add create_output_directories, which throws FileWriteError
  ("failed to write <dir>: cannot create directory (<reason>)") so output
  directory failures read like every other output write. Used by the CLI
  output paths, write_exports, and the semantic stat index / cache writers
  (whose daemon callers already log FileWriteError as non-fatal).
- cli/main: one std::exception handler prints `cgraph: <what>` and exits 1.
- graphd, cgraph-client, cgraph-mcp mains: same last-resort handler, so
  e.g. `graphd --idle-timeout abc` or a deleted cwd exits 1, not 134.
- mcp_server: a throwing forwarder fails only that request (-32603) instead
  of escaping and killing the stdio loop.

Graph output unchanged; kIndexVersionKey not bumped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 3, 2026 •

Copy link
Copy Markdown

PR brief: 0 agent commits · run the full suite

Warning

Run the full suite. The graph cannot vouch for this diff, so every test file is selected. Nothing is skipped; this is the safe default, not a failure.

2 files have no graph node (config, asset, or unextracted).

If these files cannot change which tests should run, add a pattern for them to ignore. If they feed tests (fixtures, data), this outcome is correct.

src/engine   ████████████████████████  1
tests/smoke  ████████████████████████  1
All 2 unmapped files
src/engine/CMakeLists.txt
tests/smoke/CMakeLists.txt

Computed at 918dc77 against base 7bb2413.

Summary PR #179
Intent no checkpoints on this branch (4 commits)
Since push e4d09b5 1 new commit, 0 changed files, 0 tests reached

What each commit did

Commit Intent Files Reaches Ran before push
4e04d24 fix(cli): exit 1 instead of aborting on uncaught filesystem… no checkpoint 12 files
12a7183 fix: review round 1 -- daemon/MCP exception safety, numeric… no checkpoint 16 files
36f5786 fix: review round 2 -- portable demangle test, echo readabl… no checkpoint 5 files
918dc77 fix(atomic-write): name the OS reason from the error_code o… no checkpoint 1 file

Claims checked

no checkpoint on this branch makes a claim the graph can check

blastline 0.13.0. Selection is advisory unless your workflow gates on it. How selection works. Intent: 0 of 4 commits. Not checked: symbol changes: no --change-context given.

taylorg009 and others added 4 commits October 2, 2026 20:54
…e errors

- graphd: exceptions in the serve loop, the initial-build thread, or the
  enrichment worker are logged with their type and stop the daemon with
  exit 1. A scope guard joins both workers and releases the endpoint on any
  exit, so nothing unwinds past a joinable std::thread (std::terminate).
  The exit-time incremental persist is skipped after a failure.
- cgraph-mcp: handle_mcp_request answers a parseable line of the wrong
  shape (`5`, `[1]`, a non-string method) with -32600, id null, and the
  stdio loop keeps serving.
- New cgraph/cli_support: integer_flag/number_flag/parse_integer replace
  std::stoi/stoll/stod/stol at every CLI and graphd flag site, so a bad
  value exits 2 with "<prog>: --depth expects an integer, got 'abc'";
  describe_exception puts the demangled type in last-resort handlers.
- Tests: daemon_server_test (fatal serve-loop and build-thread failures),
  mcp_main_test (stdio loop), mcp_server_test, cli_support_test,
  cli_main_test (--depth abc), atomic_write_test (create_output_directories).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- cli_support_test: assert only "filesystem_error: " and the path; libc++
  demangles the type as std::__1::__fs::filesystem::filesystem_error.
- mcp_server: an Invalid Request echoes the request's id when it is an
  object member that is a string, number or null; otherwise null.
- cli_support: guard <cxxabi.h> for MSVC, whose type_info::name() is
  already readable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Resolve conflicts with main:
- daemon_server.cpp: take main's LocalSocket serve loop (#203) and re-apply
  this branch's exception safety on top (fatal flag, worker try/catch,
  ScopeExit worker join + endpoint release, skip the dirty flush after a
  failure, exit 1). The per-connection close guard is dropped: LocalSocket
  closes itself.
- mcp_server.cpp: keep respond() + the json-exception wrapper and thread
  main's listed_tools through both; keep main's new public helpers.
- mcp/main.cpp, semantic_cache.cpp, tests: keep both sides.

Fix the failing and soon-to-fail tests:
- cli_support_test: libc++ quotes the path (["/x"]); match "/x" only.
- describe_exception: drop MSVC's "class "/"struct " key so every platform
  names the type the same way (cli_support_test expected that already).
- mcp_main_test, atomic_write_test, daemon_server_test: guard the POSIX-only
  cases (fork/pipe, geteuid, chmod) on Windows, which main now builds in CI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
create_output_directories passed error.value() to failure(), which reads it
as an errno. On Windows std::filesystem reports Win32 codes in
system_category, so ERROR_ACCESS_DENIED (5) printed as EIO. Use
error.message() instead; the text is unchanged on POSIX.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@tgod009 tgod009 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.

Independent review of the main merge (e4d09b5) plus 918dc77: NON-BLOCKING, ready to merge.

  • daemon_server.cpp resolution: both workers are joined on every exit path (stop_workers is declared after build_thread and enrichment_worker, so it runs first); the endpoint is cleaned exactly once; the listener is reset only after the last accept; nothing from main (#203, #207) was dropped.
  • mcp_server.cpp: listed_tools is threaded through respond() and the json-exception wrapper.
  • MSVC/Windows: no unguarded POSIX calls; POSIX-only tests are skipped on Windows.
  • The one SHOULD (create_output_directories naming Win32 codes as errno) is fixed in 918dc77.

CI on 918dc77: 12/12 green, including windows-default and windows-release. Local ctest: 104/104.

Note, not this PR: the earlier windows-release failure on e4d09b5 ("incremental state re-persisted to graph.json") came from "cannot rename graph.json.tmp into place: Access is denied". The test polls graph.json with an ifstream while the daemon renames over it, and Windows refuses to replace an open file. The rename code and that test loop are unchanged from main.

@taylorg009
taylorg009 merged commit 12a0957 into main Oct 19, 2026
12 checks passed
taylorg009 added a commit that referenced this pull request Oct 21, 2026
…fix the graph.json Windows flake (#212)

* fix(daemon): do not stop on a file Windows reports as access denied

std::filesystem::exists(path) throws when the path cannot be stat'ed.
Windows reports a file that an editor is replacing as "Access is
denied", so the enrichment walk (classify_cached_file,
is_valid_cached_record) and the watcher's incremental update threw, and
the daemon stopped: exit 1 since #179, std::terminate before. Seen on
windows-default CI as "enrichment refresh failed: ... exists: Access is
denied ... docs\guide.md".

Use the error_code overloads. A file that cannot be checked counts as
absent for this pass, as the neighbouring file_size/last_write_time
checks already treat it; the watcher reports it again once written.
Regression tests reproduce the throw on POSIX with an unsearchable
parent directory.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(daemon): read daemon-written files with FILE_SHARE_DELETE on Windows

daemon_server_test polled graph.json and the stat index with std::ifstream
every 20 ms. MSVC opens without FILE_SHARE_DELETE, so the daemon's
MoveFileExW replace failed with access denied for its whole 10 s retry
window and the test gave up (windows-release flake "incremental state
re-persisted to graph.json"). Restart Manager named the test process as
the only holder. read_shared opens with FILE_SHARE_DELETE on Windows, so
the replace goes through while a read is in flight; POSIX is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(file-cache): a failed exists() is Unreadable, not Deleted

Review of #212: classify_cached_file returned Deleted when exists() failed,
so a full stat rescan that met a file mid-save on Windows dropped that
file's nodes until the next edit. Unreadable makes every caller keep the
last verified entry (full_stat_index_rescan refuses, the incremental path
and the chunk planner skip). A clean not-found is still Deleted.

incremental_update_test now indexes the file before locking it and checks
its nodes survive the unreadable Modified event; a mutant that erases
indexed files on a failed check fails it. Drop the redundant <windows.h>
block in daemon_server_test (unreadable_file.hpp already includes it).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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.

2 participants