diff --git a/crates/terraphim_lsp/Cargo.lock b/crates/terraphim_lsp/Cargo.lock index 3aa2da46e..c71c698a6 100644 --- a/crates/terraphim_lsp/Cargo.lock +++ b/crates/terraphim_lsp/Cargo.lock @@ -1597,7 +1597,7 @@ dependencies = [ [[package]] name = "terraphim_lsp" -version = "1.22.0-rc.4" +version = "1.22.0-rc.5" dependencies = [ "criterion", "env_logger", diff --git a/crates/terraphim_lsp/Cargo.toml b/crates/terraphim_lsp/Cargo.toml index 8e89dc0d3..0070165f3 100644 --- a/crates/terraphim_lsp/Cargo.toml +++ b/crates/terraphim_lsp/Cargo.toml @@ -50,7 +50,7 @@ resolver = "2" members = [".", "../terraphim_lsp_core"] [workspace.package] -version = "1.22.0-rc.4" +version = "1.22.0-rc.5" edition = "2024" rust-version = "1.91" diff --git a/crates/terraphim_lsp/tests/stdio_tests.rs b/crates/terraphim_lsp/tests/stdio_tests.rs index 837f48101..faf5436aa 100644 --- a/crates/terraphim_lsp/tests/stdio_tests.rs +++ b/crates/terraphim_lsp/tests/stdio_tests.rs @@ -15,8 +15,29 @@ fn frame(message: &serde_json::Value) -> Vec { format!("Content-Length: {}\r\n\r\n{body}", body.len()).into_bytes() } -/// Observe an actual response before advancing the client handshake. Answer -/// refresh requests while preserving every raw server message for assertions. +/// Preserve every frame and acknowledge server requests as a real client does. +fn receive_message( + receiver: &mpsc::Receiver, + stdin: &mut std::process::ChildStdin, + seen: &mut Vec, +) -> serde_json::Value { + let message = receiver + .recv_timeout(Duration::from_secs(30)) + .unwrap_or_else(|_| panic!("no next server frame; received {seen:?}")); + let value: serde_json::Value = serde_json::from_str(&message).unwrap(); + seen.push(message); + if value["method"] == "workspace/diagnostic/refresh" { + stdin + .write_all(&frame(&serde_json::json!({ + "jsonrpc": "2.0", "id": value["id"], "result": null, + }))) + .unwrap(); + stdin.flush().unwrap(); + } + value +} + +/// Observe an actual response before advancing the client handshake. fn receive_response( receiver: &mpsc::Receiver, stdin: &mut std::process::ChildStdin, @@ -24,19 +45,7 @@ fn receive_response( id: i64, ) -> serde_json::Value { loop { - let message = receiver - .recv_timeout(Duration::from_secs(30)) - .unwrap_or_else(|_| panic!("no response {id}; received {seen:?}")); - let value: serde_json::Value = serde_json::from_str(&message).unwrap(); - seen.push(message); - if value["method"] == "workspace/diagnostic/refresh" { - stdin - .write_all(&frame(&serde_json::json!({ - "jsonrpc": "2.0", "id": value["id"], "result": null, - }))) - .unwrap(); - stdin.flush().unwrap(); - } + let value = receive_message(receiver, stdin, seen); if value["id"] == id && value.get("method").is_none() { assert!(value.get("error").is_none(), "response {id}: {value}"); return value; @@ -48,6 +57,7 @@ fn receive_response( /// order. Complete initialize before sending normal traffic, and shutdown /// before exit, just as a conforming LSP client does. fn session(workspace: serde_json::Value) -> Vec { + let expects_refresh = workspace["diagnostics"]["refreshSupport"] == true; let mut child = Command::new(env!("CARGO_BIN_EXE_terraphim-lsp")) .stdin(Stdio::piped()) .stdout(Stdio::piped()) @@ -113,6 +123,14 @@ fn session(workspace: serde_json::Value) -> Vec { let trim = receive_response(&receiver, &mut stdin, &mut seen, 2); assert!(trim["result"]["candidates"].is_u64(), "{trim}"); + // The refresh is deliberately spawned: the command response is not a + // barrier for that independent server request. Keep the client alive and + // acknowledge it before shutdown, whichever message arrived first. + if expects_refresh { + while refreshes(&seen) == 0 { + receive_message(&receiver, &mut stdin, &mut seen); + } + } stdin .write_all(&frame( &serde_json::json!({"jsonrpc": "2.0", "id": 3, "method": "shutdown"}), diff --git a/docs/plans/public-lsp-packages.md b/docs/plans/public-lsp-packages.md index 638df11ec..c9e862fc3 100644 --- a/docs/plans/public-lsp-packages.md +++ b/docs/plans/public-lsp-packages.md @@ -20,7 +20,7 @@ change. The original `alternative.add` result remains unchanged. - `terraphim_lsp_core` is version `1.23.1`, with public dependencies on types `1.22.3`, automata `2.1.0`, alternatives `0.1.1` and Lab `0.1.1`. -- `terraphim_lsp` is the `1.22.0-rc.4` candidate and depends on core `1.23.1`. +- `terraphim_lsp` is the `1.22.0-rc.5` candidate and depends on core `1.23.1`. - Both packages restrict publication to crates.io and include the Apache-2.0 licence notice and their own test fixtures. No private Git history, registry credentials or operational files are part of the snapshot. @@ -63,8 +63,8 @@ for public registry sources, builds the package, and tests the extracted bytes outside the source checkout. A second job reproduces the qualified archive before requesting publication credentials. -Publish `terraphim_lsp_core-v1.23.1` first, then -`terraphim_lsp-v1.22.0-rc.4`. Existing crates use the `crates-io` environment and +Core `terraphim_lsp_core-v1.23.1` is already published. The next server tag is +`terraphim_lsp-v1.22.0-rc.5`; the published RC4 crate and source tag remain immutable. Existing crates use the `crates-io` environment and crates.io trusted publishing. A first-ever crate name uses a deliberate manual run on its exact tag with `auth_mode=bootstrap` and the `crates-io-bootstrap` environment. This temporary path reads the existing `CARGO_REGISTRY_TOKEN` @@ -107,3 +107,22 @@ The earlier private-source producer remains manually dispatchable for its separate provenance work, but it does not publish releases. The public binary publisher accepts only the reviewed crate workflow call and no longer consumes private producer artifacts. The RC4 release uses public package provenance. + +## Cross build directory correction + +RC4 crate publication succeeded, but its musl binary lanes failed before +compilation. The pinned Cross wrapper maps `CARGO_TARGET_DIR` to `/target` while +forwarding `CARGO_BUILD_BUILD_DIR` unchanged. An absolute host build directory +therefore pointed outside the writable container mount. For Cross build commands +only, the helper now omits that redundant override so Cargo defaults to the +mapped target directory. Native command environments and filesystem permissions +remain unchanged. + +The original failure and correction were reproduced with actual non-root Cargo +1.96 containers. Both full musl builds of the already-published RC4 crate then +passed using pinned Cross revision `29d00c7803f221f1b3f35e561b03792368fb8339` and +the corrected helper. The x86-64 binary also passed actual version, save/reopen +and synonym-export protocol checks; ARM64 was architecture-checked without a +native runtime claim. These diagnostic outputs are not published release assets. +RC5 changes the server release identity and build helper; core 1.23.1 remains +unchanged. The normal release workflow must qualify and publish the new source. diff --git a/release/lsp-crates.json b/release/lsp-crates.json index 68c826a15..a70b781a7 100644 --- a/release/lsp-crates.json +++ b/release/lsp-crates.json @@ -12,7 +12,7 @@ }, { "name": "terraphim_lsp", - "version": "1.22.0-rc.4", + "version": "1.22.0-rc.5", "path": "crates/terraphim_lsp/Cargo.toml" } ] diff --git a/scripts/lsp_public_binary.py b/scripts/lsp_public_binary.py index dd353eaf5..29446187e 100644 --- a/scripts/lsp_public_binary.py +++ b/scripts/lsp_public_binary.py @@ -129,21 +129,26 @@ def prepare(root, lane, expected, item): return source_receipt(lane, expected) -def environment(lane): +def environment(lane, *, for_cross=False): env = {k: v for k, v in os.environ.items() if not k.startswith(('CARGO_', 'RUSTC_', 'RUSTFLAGS', 'GH_', 'GITHUB_TOKEN', 'GITEA_', 'OP_'))} for key in ('RUSTC', 'RUSTDOC', 'RUSTUP_TOOLCHAIN'): env.pop(key, None) env.update({'CARGO_HOME': str(lane / 'cargo'), 'CARGO_TARGET_DIR': str(lane / 'target'), 'CARGO_BUILD_BUILD_DIR': str(lane / 'target'), 'CARGO_TERM_COLOR': 'never', 'RUSTC_WRAPPER': '', 'RUSTC_WORKSPACE_WRAPPER': ''}) + if for_cross: + # Cross maps CARGO_TARGET_DIR to /target inside its container, but the + # pinned wrapper forwards CARGO_BUILD_BUILD_DIR unchanged. Let Cargo + # default its build directory to that mapped target, never a host path. + env.pop('CARGO_BUILD_BUILD_DIR', None) return env -def command(argv, cwd, lane, name): +def command(argv, cwd, lane, name, *, for_cross=False): logs = lane / 'logs'; logs.mkdir(exist_ok=True) path = logs / (name + '.log') with path.open('xb') as output: - result = subprocess.run(argv, cwd=cwd, env=environment(lane), stdout=output, + result = subprocess.run(argv, cwd=cwd, env=environment(lane, for_cross=for_cross), stdout=output, stderr=subprocess.STDOUT, timeout=3600, check=False) record = {'argv': argv, 'exitCode': result.returncode, 'log': path.name, 'sha256': publisher.digest(path)} require(result.returncode == 0, 'Build command failed: ' + name) @@ -374,7 +379,7 @@ def build(lane, expected, target, cross_image=None): invocation = ['rustup', 'run', expected['toolchain'], 'cross'] else: invocation = cargo - commands.append(command(invocation + ['build', '--locked', '--offline', '--release', '--target', target, '--bin', 'terraphim-lsp'], source, lane, 'build-' + target)) + commands.append(command(invocation + ['build', '--locked', '--offline', '--release', '--target', target, '--bin', 'terraphim-lsp'], source, lane, 'build-' + target, for_cross=cross_image is not None)) return stage_binary(lane / 'target' / target / 'release/terraphim-lsp', target, lane, expected, commands, {'crossImage': cross_image, 'crossImageSelection': 'Runtime digest resolved from upstream 0.2.5 tag; the recorded digest is used for this build.'} if cross_image else {}) diff --git a/tests/test_lsp_public_binary.py b/tests/test_lsp_public_binary.py index aa303bf71..9db6d2972 100644 --- a/tests/test_lsp_public_binary.py +++ b/tests/test_lsp_public_binary.py @@ -111,6 +111,36 @@ def test_real_subprocess_log_is_saved_and_hash_bound(self): b.command([sys.executable,'-c','raise SystemExit(4)'],lane,lane,'real-failure') self.assertTrue((lane/'logs/real-failure.log').is_file()) + def test_cross_environment_omits_host_build_dir_without_changing_native(self): + lane = self.root / 'lane' + previous = os.environ.get('CARGO_BUILD_BUILD_DIR') + try: + os.environ['CARGO_BUILD_BUILD_DIR'] = '/unrelated/inherited/build' + native = b.environment(lane) + cross = b.environment(lane, for_cross=True) + finally: + if previous is None: + os.environ.pop('CARGO_BUILD_BUILD_DIR', None) + else: + os.environ['CARGO_BUILD_BUILD_DIR'] = previous + self.assertEqual(native['CARGO_BUILD_BUILD_DIR'], str(lane / 'target')) + self.assertNotIn('CARGO_BUILD_BUILD_DIR', cross) + self.assertEqual(cross['CARGO_TARGET_DIR'], str(lane / 'target')) + self.assertEqual({k: v for k, v in native.items() if k != 'CARGO_BUILD_BUILD_DIR'}, cross) + + def test_actual_command_child_receives_cross_specific_environment(self): + lane = self.root / 'child-env'; lane.mkdir() + command = [sys.executable, '-c', + 'import json,os; print(json.dumps({k:os.environ.get(k) for k in ' + '["CARGO_BUILD_BUILD_DIR","CARGO_TARGET_DIR"]}))'] + b.command(command, lane, lane, 'native-env') + b.command(command, lane, lane, 'cross-env', for_cross=True) + native = json.loads((lane / 'logs/native-env.log').read_text()) + cross = json.loads((lane / 'logs/cross-env.log').read_text()) + self.assertEqual(native['CARGO_BUILD_BUILD_DIR'], str(lane / 'target')) + self.assertIsNone(cross['CARGO_BUILD_BUILD_DIR']) + self.assertEqual(cross['CARGO_TARGET_DIR'], native['CARGO_TARGET_DIR']) + def test_architecture_refuses_unknown_and_mismatched_format(self): for target in (*b.TARGETS,'universal-apple-darwin'): path=self.root/target;path.write_bytes(self.executable(target));self.assertTrue(b.architecture(path))