diff --git a/editors/vscode/src/feature/context.ts b/editors/vscode/src/feature/context.ts index 4566d85bb..dd9c2f23b 100644 --- a/editors/vscode/src/feature/context.ts +++ b/editors/vscode/src/feature/context.ts @@ -148,6 +148,37 @@ class ContextTreeProvider implements vscode.TreeDataProvider { } } +/** Re-sync an open document with the server (didClose + didOpen) so the + * editor re-requests every language feature — tokens, links, hints — and + * the recompile publishes fresh diagnostics. Used after a context switch: + * the pull-based server only re-targets the session. The language-id + * round-trip is the only stable way to force a full re-sync; buffer + * content and unsaved edits survive it. */ +export async function resyncDocument(uri: string) { + const doc = vscode.workspace.textDocuments.find( + (candidate) => candidate.uri.toString() === uri, + ); + if (!doc) { + return; + } + const language = doc.languageId; + resyncing.add(uri); + try { + await vscode.languages.setTextDocumentLanguage(doc, "plaintext"); + await vscode.languages.setTextDocumentLanguage(doc, language); + } catch { + // The document was closed mid-round-trip; nothing left to resync, + // and the caller's UI refresh must still run. + } finally { + resyncing.delete(uri); + } +} + +/** Documents mid-resync: their transient plaintext hop must not be + * mistaken by detectCxxFragment for a fragment awaiting detection — the + * detector would race the restore and pin a c/cuda-cpp file to cpp. */ +const resyncing = new Set(); + export function registerCompilationContext(client: LanguageClient, ext: vscode.ExtensionContext) { const status = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 100); status.command = "clice.switchContext"; @@ -204,9 +235,12 @@ export function registerCompilationContext(client: LanguageClient, ext: vscode.E } else if (!switched?.success) { vscode.window.showWarningMessage("clice: failed to switch compilation context"); } else { - // The switch only marks the session dirty (pull-based server); - // fire a cheap feature request so diagnostics and inactive - // regions refresh without waiting for user interaction. + // The server is pull-based: the switch only re-targets the + // session, and the refresh is the client's job. + await resyncDocument(uri); + // Editors with automatic feature pulls disabled stop at the + // reopen (didOpen alone compiles nothing); one cheap explicit + // pull guarantees diagnostics come back regardless. void vscode.commands.executeCommand( "vscode.executeDocumentSymbolProvider", vscode.Uri.parse(uri), @@ -279,7 +313,7 @@ export function registerCompilationContext(client: LanguageClient, ext: vscode.E // by some C++ TU (it has compilation contexts), flip its language so // the whole toolchain attaches. async function detectCxxFragment(document: vscode.TextDocument) { - if (document.languageId !== "plaintext") { + if (document.languageId !== "plaintext" || resyncing.has(document.uri.toString())) { return; } if (!/\.(def|inc|inl|tpp|ipp)$/.test(document.uri.fsPath)) { diff --git a/editors/vscode/src/test/e2e.test.ts b/editors/vscode/src/test/e2e.test.ts index 4380a4afd..4bf609956 100644 --- a/editors/vscode/src/test/e2e.test.ts +++ b/editors/vscode/src/test/e2e.test.ts @@ -2,6 +2,8 @@ import * as assert from "assert"; import * as path from "path"; import * as vscode from "vscode"; +import { resyncDocument } from "../feature/context"; + // E2E smoke tests against a real clice binary. The binary path comes from // CLICE_EXECUTABLE; without it (plain `pnpm test`) the suite is skipped. // The workspace folder is set by .vscode-test.mjs and selects the scenario. @@ -169,6 +171,37 @@ suite("clice E2E", function () { current.context && current.context.uri.includes("main.cpp"), "currentContext should report the switched host", ); + + // The client contract: after a successful switch the extension + // re-syncs the document (didClose + didOpen) so every feature + // refreshes. A fresh diagnostics publish is the proof the + // round-trip reached the server — currentContext alone would pass + // without any reopen (it reads the persisted choice directly). + const diagnosticsChanged = new Promise((resolve, reject) => { + const timer = setTimeout(() => { + subscription.dispose(); + reject(new Error("no diagnostics publish after resync")); + }, 30 * 1000); + const subscription = vscode.languages.onDidChangeDiagnostics((event) => { + if (event.uris.some((changed) => changed.toString() === uri)) { + clearTimeout(timer); + subscription.dispose(); + resolve(); + } + }); + }); + await resyncDocument(uri); + await diagnosticsChanged; + assert.strictEqual( + document.languageId, + "cpp", + "language id restored after the resync round-trip", + ); + const resynced = await client.sendRequest("clice/currentContext", { uri }); + assert.ok( + resynced.context && resynced.context.uri.includes("main.cpp"), + "switched context should survive the resync", + ); }); test("completion", async function () { diff --git a/tests/integration/extensions/test_context_switching.py b/tests/integration/extensions/test_context_switching.py index 7e2d43988..3c51767b4 100644 --- a/tests/integration/extensions/test_context_switching.py +++ b/tests/integration/extensions/test_context_switching.py @@ -9,8 +9,8 @@ from tests.tools.compile_commands import write_cdb, write_entries from tests.tools.workspace import get_field -from tests.tools.checks import assert_clean_compile, assert_has_errors -from tests.tools.checks import MTIME_GRANULARITY, wait_for_recompile +from tests.tools.checks import assert_clean_compile, assert_has_errors, get_errors +from tests.tools.checks import MTIME_GRANULARITY, SETTLE_TIME, wait_for_recompile async def test_source_command_switch(client, tmp_path): @@ -400,3 +400,52 @@ async def test_chain_change_resynthesizes(client, tmp_path): after = {p.name: p.stat().st_mtime_ns for p in artifact_dir.iterdir()} assert after != snapshot, "stale preamble must be re-synthesized" + + +async def test_switched_context_survives_reopen(client, tmp_path): + """The client resync contract: after a successful switch the client + closes and reopens the document (the pull-based server only re-targets + the session); the reopened compile runs under the persisted choice.""" + (tmp_path / "main.cpp").write_text( + "#ifdef USE_B\nint broken() { return undefined_b_symbol; }\n#endif\n" + "int main() { return 0; }\n" + ) + write_entries(tmp_path, [("main.cpp", ["-DUSE_A"]), ("main.cpp", ["-DUSE_B"])]) + await client.initialize(tmp_path) + + uri, _ = await client.open_and_wait(tmp_path / "main.cpp") + assert_clean_compile(client, uri) + + query = await client.query_context(uri) + contexts = get_field(query, "contexts", []) + assert get_field(query, "total") == 2, f"expected both entries: {contexts}" + target_hash = next( + get_field(c, "commandHash") + for c in contexts + if "USE_B" in (get_field(c, "label") or "") + ) + + switch = await client.switch_context( + uri, uri, command_hash=target_hash, epoch=get_field(query, "epoch") + ) + assert get_field(switch, "success") is True, f"switch failed: {switch}" + + client.close(uri) + await asyncio.sleep(SETTLE_TIME) + client.diagnostics.pop(uri, None) + + # The close publishes an empty retract that can race the reopen's + # first publish; poll past it for the real compile's errors. + uri2, _ = await client.open_and_wait(tmp_path / "main.cpp") + for _ in range(50): + if get_errors(client.diagnostics.get(uri2, [])): + break + await asyncio.sleep(0.2) + assert any( + "undefined_b_symbol" in d.message for d in client.diagnostics.get(uri2, []) + ), f"expected the USE_B error after reopen: {client.diagnostics.get(uri2, [])}" + + current = await client.current_context(uri2) + assert get_field(get_field(current, "context"), "commandHash") == target_hash, ( + f"persisted choice must survive the reopen: {current}" + )