Skip to content

mcpp-language-server 0.0.5: issue #23 fixed, an autosaved half-typed import no longer stalls clangd, restarts back off, C++26 aligned, one-click diagnostic bundle - #26

Merged
Sunrisepeak merged 35 commits into
mainfrom
release/0.0.5
Sep 26, 2026
Merged

Sunrisepeak merged 35 commits into
mainfrom
release/0.0.5

Conversation

@Sunrisepeak

@Sunrisepeak Sunrisepeak commented Sep 26, 2026 •

Copy link
Copy Markdown
Owner

Closes #23. Includes #25 (S2 0.3.0, by speak-agent), with its consumer side and evidence.

Issue #23 is fixed: modules are built again for projects compiled with LTO for the MSVC ABI. An autosave of a half-typed import no longer stalls clangd, restarts can no longer leave it stuck, a project mixing C++23 and C++26 reads both, and one command exports everything a problem report needs, with your name, paths and secrets replaced.

The analysis of #23, with its Windows CI measurements, is .agents/docs/2026-09-26-issue-23-lto-module-scan.md. The plan (F1–F18), its decisions D1–D5, the split into work packages, the implementation record (§8) and the C++26 alignment (§9) are in .agents/docs/2026-09-26-issue-23-fix-plan.md. The user-facing summary is CHANGELOG [0.0.5]. clangd's own defects are registered in #24.

What changed

Area Fix
#23 root cause Every engine command compiles only (-c), so clangd's module scan no longer fails on LTO requires -fuse-ld=lld; -flto stays (F1)
Crashes The file clangd names in its crash context is set aside, not the files touched in the last ten seconds; exit code, file and exception code are in the report (F3)
Restarts Each cause (plan, recovery, crash) has a budget of 3 in 10 minutes and is then backed off 1/2/4/8 minutes, never refused. Restart clangd is never counted, nor is a switch of toolchain or context (F14)
Typing with autosave clangd reads imports from disk (UP-14). A file whose disk text would stall it (import hello., or an import of a module that does not exist yet) is set aside before clangd builds it and handed back when safe. Edited files' disk imports get stand-ins at once. Plan churn no longer restarts clangd (F13, F16)
Partial answers S2 0.3.0 (#25): a document with data and error diagnostics describes all but what they name, by path; mcppls uses the rest and reports each missing part (producer-partial, F10)
Cold start clangd waits for the build tool's model when a build system was found, rather than starting on a provisional one (F4)
Diagnostics A rejected command is an environment issue (F6). A missing ; is reported on its directive (WA-CLANGD-006). An unsaved import of a project module is information (WA-CLANGD-007) (F11, F12)
Completion A space after import opens the module list (four layers against extra work); module keywords come from mcppls and are merged with clangd's (F9, F15)
Coloring export of every export declaration (F8); C++26 contract keywords in VS Code
C++26 The module units of a context are read with one standard, the newest they name. Sources nothing describes are read as C++26 where the compiler takes it. standard in the status profile
Observability clangd logs at info into memory. Incidents are written under the workspace cache, with editor-versus-disk lines and per-thread CPU. Plan diffs are logged. Workaround premise guards (F17)
Bundle Export Diagnostic Bundle / mcppls report --bundle / mcppls.exportBundle. It is redacted (home, user, host, secrets) and not written if anything is left over; never uploaded. Reports are redacted by default (F18, S3-5.5-3)
Also UTF-8 BOM (F2); package-manager directories skipped (F5); macOS 11 again with mcpp 2026.9.26.1 (F7)

Proof

  • New conformance fixtures, each failing on 0.0.4 and passing here: typing-autosave, clangd-crash-context, compdb-rejected-command, mcpp-emit-wait, compdb-lto-msvc, inferred-bom, inferred-cxx26, compdb-mixed-standards, mcpp-emit-partial, completion-keywords, diagnostic-bundle.
  • Locally on Linux:
    • unit tests 29/29 (dev and release profiles);
    • devtools check all and validate.py;
    • cross-builds for Windows and macOS;
    • every Linux CI fixture;
    • VS Code suites (main, stress, conflicts, from source and from a packaged VSIX);
    • the Neovim smoke test.
  • On the hello project, where this started:
    • impo and expo offer import and export module from the first letter;
    • with import hello. on disk, every clangd thread used 0 CPU ticks over 20 s (0.0.4: about 2000).

Version 0.0.5 (devtools version --set); clangd rebuilds its module cache once after the upgrade, since every command changed.

Sunrisepeak and others added 30 commits September 26, 2026 07:04
…implementation split; #24 is the register of upstream defects

The analysis of issue #23 (Windows, MSVC, LTO: clangd's module scan fails on commands without -c) with the Windows CI measurements, the hello-project investigation (a half-typed import saved to disk spins clangd past WA-CLANGD-001), and the fix plan F1-F18 with its decisions D1-D5 and the split into work packages for one 0.0.5 pull request. The contributing skill names issue #24 as the single register of upstream defects.
….9.26.1

The macOS server said minos 14.0 although it imports only three libSystem symbols from macOS 10.12, because mcpp ignored macos_deployment_target when cross-compiling from Linux (mcpp#685, UP-M1 in #24). mcpp 2026.9.24.1 honours it; CI now builds with 2026.9.26.1 and the target is 11.0, the same as the bundled clangd's (F7).
…o a windows-msvc build with -flto has its modules built again

Issue #23: the engine database dropped the build's own -c with the other build-only arguments and never put one back, while it kept link arguments such as -flto. Without -c the driver plans a link, and for the windows-msvc target a check made only for the link step, "LTO requires -fuse-ld=lld", made clangd's P1689 module scan fail for every unit: no module was built, imports reported as not found, and hovers across modules answered nothing, while each file's own AST looked fine.

translate_gnu, translate_msvc and kit_arguments now end with exactly one -c (COMPILE_ONLY), before -x c++-module, so every entry derived from them carries it too: project units, files opened outside the model, prime units, the standard library's units and stand-ins. clangd's own -fsyntax-only still decides what it builds, and -flto stays: the project's LTO is not the engine's business.

The fixture compdb-lto-msvc writes the compile_commands.json of such a project (clang++ --target=x86_64-pc-windows-msvc -flto, with --no-default-config so no configuration file picks lld) and runs on Linux, where the driver raises the same error. With the previous server its hover and diagnostics checks fail and clangd logs the scan failure six times; with this one they pass and nothing fails to scan.
… module keywords come from mcppls whatever clangd is doing

A space is now a completion trigger character for clients that drop the other spaces themselves (VS Code and its forks, by clientInfo) or that ask for it with initializationOptions.completion.triggerOnSpace; false turns it off for anyone. The workspace gates every space-triggered request at its routing entry on the line's own text before the cursor: only `[export] import ` followed by nothing passes, and goes to mcppls's own engine for the module names; anything else is answered at once with an empty list, never reaching clangd, the index or the log. The report counts what that cost (roots[].completion.spaceTrigger). Import completion's module names are kept by the index until a module is declared, dropped or changes role, instead of being rebuilt, each with a scan of every file, on every request.

The module-syntax keywords -- import, export import, module;, export module, module and module :private; -- are offered where each can begin a declaration: at global scope, outside comments and literals, each only where it can appear (export import in an interface unit, module; before anything else, the declarations in a file that has none, the private fragment once in a primary interface). clangd offers some of them but not the combined forms, and nothing at all while it is stuck on the file, which is how a spinning clangd took `import` away in the hello project. They are merged into clangd's answer without duplicate labels, replace an error from it, and go out on their own, as an incomplete list, when clangd has not answered within 1.5 s. For VS Code, accepting import or export import opens the module list at once. After `export module ` nothing is suggested (D5).

Fix plan 2026-09-26 F9 and F15, decisions D4 and D5.
…a trigger character, a client name and number bounds

completion-keywords drives the server as VS Code (the scenario's new "client-info"): the space is advertised; a space after `import ` or `export import ` gets the module list, one after `int x = `, `import  ` or `export module ` gets nothing at once; the module keywords appear where each fits and not in a function body, and on their own for a file clangd cannot answer for yet; the report counts the space trigger's cost. engine-none checks the keywords and the gate with no core engine at all. Against 0.0.4 the fixture fails nine checks.

The runner's completion-contains takes "trigger" (a request sent as typing that character asked for it), several labels, "exact" and "absent"; completion-empty and capabilities are new kinds; an expectation can say "at-least". The fixture runs in CI on every platform.
…y other space is dropped before it is sent

The extension's middleware answers a completion that a typed space asked for with nothing unless the line up to the cursor is `import ` or `export import ` -- the server's own gate, in TypeScript, tested against the server's cases -- so the space the server advertises as a trigger character costs nothing anywhere else. The setting mcppls.completion.triggerOnSpace (default true) is sent as initializationOptions.completion.triggerOnSpace; off, the server does not advertise the space. The settings pages and the Neovim README say how other editors ask for it.
…e module keywords

S3 section 6.2: a server may make a space a completion trigger character for the module names after `import`; one that does answers every other space-triggered request at once, empty, without its semantic engine (S3-6.2-1), and advertises the space only to a client that asks with initializationOptions.completion.triggerOnSpace, or that it knows drops the other spaces itself, never to one that said false (S3-6.2-2, S3-6.2-3). The module-syntax keywords are offered where each can begin a declaration, merged with the semantic engine's result, and without it when it does not answer in time (S3-6.2-4, S3-6.2-5). Additive: protocol version 1. Evidence in conformance/traceability.json.
…cked in a zip, and is checked before it is written

Issue #23 fix plan F18. A new directory of modules, mcppls.bundle.*, that nothing in the engines depends on.

redact: the user's home directory becomes ~ in every spelling a log or a report carries it in -- either separator, a backslash doubled by JSON or doubled again, an escaped slash, percent-encoding in a file:// URI, either case of the drive letter, the WSL view of a Windows drive, a Windows profile under its 8.3 name -- and only as a whole path, so /home/speaker is not /home/speak. Other people's profile directories (/home/<name>, /Users/<name>, C:\Users\<name>) get their own placeholder, the same one every time. A distinctive user or host name is replaced wherever it stands as a word; a short or common one (runner, admin, a) only where it names a directory, so that a bundle of such a user is not refused forever. Secrets are found by their shape (GitHub, GitLab, Slack, OpenAI and AWS tokens, credentials in a URL, Bearer) and by their names (JSON keys, NAME=value, -DNAME=value, Authorization: headers), and e-mail addresses go too. A residue check looks for the home directory, the names and known tokens again afterwards, including the home as a plain string whatever follows it.

zip: a zip written in memory, entries stored or deflated with fixed codes, CRC-32, UTF-8 names, and a reader for what it writes. The server carries no archive library.

writer: the bundle -- manifest, report, environment (system, editor, extension, settings, payload, versions, probed toolchains, a whitelist of environment variables), the server's logs of the last sessions, each root's incidents and engine database and plan, the client's log, crash dumps only when asked for -- redacted by one Redactor so an original has one placeholder in every file, checked, compressed entry by entry and kept by priority within 25 MB, written to <cache>/bundles/ (the newest five kept) or where asked. Any residue and nothing is written.

The tests cover every spelling on Linux and Windows forms, the secrets, the consistency of placeholders, the residue check, the deflate round trip and a whole bundle written by a child process whose HOME, user and cache carry a long user name in every path: no file names the user, the home or a secret, and the manifest's digests are the contents'.
…ck off instead of stopping, and a half-typed import saved to disk no longer spins clangd

clangd prints which file it crashed on (Signalled during AST worker action, Filename, and on Windows the exception code); that file is now the one set aside, and its exit code and crash context are in the report and the event, where before the files touched in the last ten seconds were guessed (issue #23: GPPDefines.ixx set aside for a crash in NormalJsonTranslator.Core.cpp). A failed module scan is read with its reason and told as an issue when it is the command's (a driver error, #23's LTO) or the project's (a header not found), never for code being typed (F3, F6).

Restarts have a budget per cause: the plan, recovery from a stuck or spinning clangd, and exits, each three in ten minutes, then backed off 1, 2, 4 and 8 minutes rather than refused, so a stuck clangd is always recovered (in the hello project it spun on at the cap until the window aged out). A switch of toolchain, profile or context, and the person's own Restart clangd (mcppls.restartClangd), are never counted. A plan restart waits two seconds so the plans that follow while a name is typed share it; a stand-in coming or going no longer restarts clangd, nor does an argument change of a file clangd does not have; and the stand-in the plan gave a module clangd could not find is no longer taken for a new provider, which dropped it again and restarted clangd (F13, F14).

clangd reads an open file's imports from disk (UP-14), so rewriting the text it is given (WA-CLANGD-001) did not keep an autosaved import hello. from spinning it. Every save and watched change is now checked on disk; such a file is set aside before clangd reads it and handed back when the disk is fixed (F16). A directive missing its ';' is reported on the directive (WA-CLANGD-006), and an import only in the unsaved buffer is information, not 'module not found' (WA-CLANGD-007) (F11, F12). A project whose build system was detected gets clangd only with the build tool's model, within 60 s, and a provisional model's exits and set-aside files are forgotten when it is replaced (F4).

clangd runs at --log=info into a 4000-line ring kept in memory; crashes, stuck or spinning clangd, files set aside, backed-off restarts and a workaround whose premise is seen broken each write an incident under the workspace cache (incidents/, the newest 20 for a week) with clangd's log, the files' editor-versus-disk lines, their commands, clangd's recent states per file, which of its threads uses the CPU, and the last plan's differences, which are logged with every change (F17).
# Conflicts:
#	src/orchestrator/workspace.cpp
…ares its module

Editors on Windows can save sources with a byte order mark, and both lexical scanners started at offset 0: the mark became the first token, `export module m;` no longer began its line, and the file was taken for a non-module unit. The project model missed the module's provider, mcppls's own engine reported a false "module 'm' not found" on every importer, the importer got a stand-in instead of the real module, and a model built from scanned sources also lost the unit's -x c++-module (issue #23's CI hit it in GalTranslPP).

The scanner of module declarations (project::scan) and the one of exported declarations (native exports) now start after the mark. Offsets stay the text's own, and ranges give the mark no column, so a file read from disk and the same file in an editor, which does not show the mark, get the same positions. base::byte_order_mark_size is the one place that knows the three bytes.

The fixture inferred-bom saves an interface and its importer with the mark: with the previous server five of its checks fail (the false unresolved-module, the import's definition, the hover across the module, the module graph and the outline); with this one all pass.
…, and vcpkg packages below the root

When no build tool describes a project, its sources are scanned. The scan skipped build output only, so issue #23's fallback model of GalTranslPP took 166 of its 349 units from vcpkg_installed/, and modules found there were reported as ambiguous with the project's own.

vcpkg_installed, vcpkg, .conan, .conan2, .xmake, .cache and .git are now skipped like target and build (hidden directories were skipped already, and are named so the list says everything it leaves out), and a directory below the root with its own vcpkg.json -- a port, an overlay, a vendored library -- is a package, not part of the project. The project's own vcpkg.json at the root changes nothing.
…e diagnostic bundle, and a report names no one by default

Issue #23 fix plan F18. The server answers workspace/executeCommand mcppls.exportBundle ({hideProjectPaths, noSourceExcerpts, includeDumps, output, client: {..., log}}) by taking its report on the event loop and writing the bundle on a thread of its own; the request is answered with {path, bytes, redactions} once it is written, or with an error whose data lists what the residue check found. The command is advertised beside the review commands. mcppls report takes --bundle <zip>, --hide-project-paths, --no-source-excerpts, --include-dumps and --no-redact, and its report now carries the payload it resolved; the kernel is shut down only after the bundle is written, since a second instance's private cache goes with it.

cxxModules/report is redacted by default (S3-5.5-3): the report the VS Code extension's Collect Report shows and a person pastes into an issue replaces the home directory, the user's and the machine's names and secrets, and keeps the project's own paths. {redact: false} gets it as before. The fixtures that read the report pass unchanged.

The conformance runner has a bundle check -- through the command, or through mcppls report --bundle with via: cli -- that opens the zip with the module's own reader, holds the manifest to the contents digest for digest and the size to its cap, and fails when any file, or the report, names the server's home directory in either spelling, a distinctive user name or its 8.3 form; --keep-bundles leaves a copy for CI to keep. The new diagnostic-bundle fixture runs it three ways.
…rted are opened by another zip reader and kept

Issue #23 fix plan F18. The fixture exports bundles on the runner's own account, whose name is in every path (runneradmin, and RUNNER~1 in TEMP, on Windows), and fails when any file names it or its home directory. The runner leaves a copy of each in RUNNER_TEMP/bundles; Python's zipfile tests every entry's CRC and lists it, and the bundles are uploaded as diagnostic-bundle-<platform>.
…e one of export module

`module;`, `export module`, `export import`, `import` and `module :private;` had their keywords colored, by the server's semantic tokens and by the VS Code grammar, but the `export` before a declaration -- `export namespace`, `export {`, `export int f()` -- got neither, and VS Code's own C++ grammar gives it no scope either, so the same keyword showed in two colors in one interface.

scan_syntax_tokens now gives a keyword token to every `export` of a module unit once its module declaration has been read, inside a namespace too, and reads what follows it as before (a brace still opens a block that hides module syntax). A file without a module declaration gets none: there `export` declares nothing. The injected grammar colors an `export` that begins a line and is not followed by `module` or `import` with the same scope as export module's.

The inferred fixture's semantic-token check now expects the keyword on `export namespace hello {`, and the grammar suite checks the new rule, including that an identifier such as `exports` is left alone.
…t Report names no one

Issue #23 fix plan F18. C++ Modules: Export Diagnostic Bundle sends mcppls.exportBundle with the extension's version, the other C/C++ extensions (installed, active, their language features off or not), the mcppls settings and the extension's own latest log lines, which it now keeps since an output channel cannot be read back; it then offers Reveal in Folder and Copy Path, and when the server wrote nothing because its check found something, a retry with project paths hidden. C++ Modules: Restart clangd sends mcppls.restartClangd (F14). A degraded or error status without a fix of its own now offers the bundle rather than the report, and Collect Report offers it too.

Collect Report's server part is redacted by the server (S3-5.5-3); the extension's own part -- its workspace folders above all -- goes through the same two rules here (src/redact.ts: the home directory as ~ in either spelling and case, a distinctive user name as <user>, a common one only as a directory), and the message says what was replaced instead of asking the user to edit it.
…s before it is shared

Issue #23 fix plan F18. The troubleshooting guide (English and Chinese) says what the report replaces (home directory, user and host names, secrets) and keeps (the project's paths), what the bundle holds file by file, that it is never uploaded and is not written at all when its check finds something left, how other editors and the command line export one, and why crash dumps and --no-redact stay out of the editor. Filing a bug now asks for the bundle. The extension's README lists its two new commands.
… bundle over its cap leaves out the oldest log first

The residue check looked for the home directory as a plain string before anything, which for a container's /root (or /home/runner) matched /usr/share/rootcerts and /opt/home/runner/x, nobody's home, and would have refused every bundle of such a user. The plain-string check is now made only for a home named for a distinctive user, and never matches a longer name. A test now writes a bundle under a 512 KiB cap with an old, badly compressing log: the log is left out and listed in the manifest's omitted, and the report, the environment, the incidents and the latest log stay.
# Conflicts:
#	.github/workflows/ci.yml
# Conflicts:
#	.github/workflows/ci.yml
#	docs/specs/CHANGELOG.md
#	src/orchestrator/routing.cpp
…ile an import is typed

clangd reads a file's imports from disk (UP-14), so an autosave of a half-typed import reaches it past everything the server does to the text it gives clangd: 'import hello.' spins it (UP-01) and an import of a module nothing provides yet deadlocks it (UP-02). A file whose text on disk would do either is now set aside before clangd builds it and handed back once the disk, or the database clangd has read, makes it safe; a build clangd began on it before is let finish for 1.5 s, else clangd is restarted without it. The plan now reads a file being edited for what its buffer and its text on disk import, so an import typed and saved gets its stand-in at once, and the scanner counts an import whose line ends before its ';', as clang does. WA-CLANGD-001's rewrite skips a byte order mark. Conformance fixture typing-autosave: it fails on 0.0.4, which left every request for the file unanswered.
…mand and mcpp-emit-wait, each failing on 0.0.4

clangd-crash-context (F3): a stand-in clangd prints a crash context naming a file nobody opened while the editor types in another, then dies; only the named file is set aside and the exit is reported with it. compdb-rejected-command (F6): commands with a value the compiler rejects make every module scan fail; the status says so as an environment issue in the compiler's words. mcpp-emit-wait (F4): a producer slower than the ten seconds after which scanned sources are planned; clangd is given no plan until the producer answers or is given up. diagnostic-code checks take line, severity and absent.
…ed, and the buttons say what they do

A file clangd would stall on because of what an autosave put on disk is reported as the code's (category code, never degraded), with why and until when. The status buttons are titled by their commands (Restart clangd, Export Diagnostic Bundle, Show Logs), a file clangd stopped answering offers Restart clangd, and a crash on a file whose disk text is known to crash clangd (UP-01 on Windows) keeps it aside only until the disk is fixed. New fixtures are in the conformance README and in CI.
…zh-CN), design record and the plan's implementation record
…standard, and sources nothing describes are read as C++26

A BMI can only be imported under the standard it was built with, and std was built with the arguments of one representative unit: in a project mixing C++23 and C++26 the C++26 files lost import std and every module ('C++26 was disabled in precompiled file'). The units of a context that take part in modules are now read with the newest standard they name, plain units keep their own, the report says which standards were seen and how many units were raised, and the status profile names the standard (S3 SemanticProfile.standard, additive). Sources nothing describes are read with the newest standard their compiler takes: C++26 for the semantic kit, GCC 14 and Clang 17 on (c++2c before Clang 20), C++23 before, where 0.0.4 always said C++23 and C++26 library names were missing. clang 23 has neither contracts nor reflection; the VS Code grammar colors contract_assert, and pre and post as contract specifiers, and the server gives contract_assert a keyword token. Fixtures inferred-cxx26 and compdb-mixed-standards, both failing on 0.0.4.
… the editor's own Restart clangd no longer collides with it

A language client registers every command a server declares; the server declared mcppls.restartClangd, the extension's own command, and the client refused to start ('command already exists'). The grammar's contract_assert rule starts at the blanks before it, where the built-in function-call rule starts, so it wins inside a function body.
… from clangd for its disk text

A module resolved through a manifest, or std with no manifest, has no unit in the engine database either; judging every import on disk by the database set aside, when it was opened, a file importing one, and never handed it back. The check now covers an import the plan has not seen yet (the one being typed), skips std and what resolves another way, and lets a file go after 30 s if the plan never gives the module a unit, as 0.0.4 did.
…too, and the review fixtures start no git maintenance

On macOS /var, /tmp and /etc are links into /private, so the workspace the server knows as /private/var/folders/.../T/x is /var/folders/.../T/x in the editor's log, and diagnostic-bundle's hidden project paths leaked in that spelling (darwin-arm64 CI). A home or workspace is now replaced in both. The review fixtures' own git commits turn off auto maintenance and gc: a recent git detaches it after a commit, and its lock file coming and going under .git/ read as the review having changed the workspace.
…wn name, not its lower-case key

On Windows a path key is lower case, and the file system refuses that spelling, so the recheck of a file set aside for 'import hello.' on disk read nothing, took that for a safe file and handed it back to clangd, which crashed on it again (win32-x64 CI, typing-autosave). Each file set aside keeps its path, and a file that cannot be read stays aside.
… not describe is said (S2 0.3.0, F10)

S2 0.3.0 (from #25) lets a single-document answer carry data together with error diagnostics whose path names each part the producer could not describe, as mcpp does after mcpp-community/mcpp#699. mcppls already used such data; it now reads the diagnostics' path and reports each missing part as a model issue (producer-partial) by its build description file. The mock mcpp answers that way when a fixture records a database and diagnostics; fixture mcpp-emit-partial, and validate.py checks the schema's path. S2-3.4-12 and S2-3.4-13 have their evidence.
…shooting (EN, zh-CN), the conformance README and the plan's record
… stays aside for the spin, and the bundle's last check runs every detector

A file set aside both because clangd spun on its text (or its module does not compile) and because its text on disk was unsafe went back to clangd as soon as the disk was safe, with the text clangd spins on; now only the disk reason is cleared. The bundle's residue check, the gate before anything is written, ran only the token-prefix detector for secrets; it now runs every detector redact() does, e-mail addresses included, and does not take a placeholder for what it replaced. Both found by an independent review of the release diff.
@Sunrisepeak
Sunrisepeak merged commit 2e80b85 into main Sep 26, 2026
47 checks passed
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.

Windows/MSVC: generated clangd compile commands with -flto but no -c break module scanning

2 participants