Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 61 additions & 1 deletion rust/lithe-core/src/debug/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1374,7 +1374,11 @@ impl DebugSession {
}
}
PendingRequest::Launch { operation_id } => {
self.transition(DebugSessionState::Running);
// Adapters may emit stopped or terminated before this acknowledgement.
// A late response completes the operation, but cannot resume or resurrect it.
if self.state == DebugSessionState::Launching {
self.transition(DebugSessionState::Running);
}
self.emit(DebugEventBody::OperationCompleted {
operation_id,
result: DebugOperationResult::Acknowledged {
Expand Down Expand Up @@ -3161,6 +3165,62 @@ mod tests {
serde_json::from_slice(&bytes[body_start..]).unwrap()
}

#[test]
fn delayed_launch_response_preserves_a_newer_stopped_or_terminal_event() {
let fixture: Value = serde_json::from_str(include_str!(
"../../../../shared/fixtures/debug/launch-event-order.json"
))
.unwrap();
for case in fixture["cases"].as_array().unwrap() {
let session_id = case["id"].as_str().unwrap();
let event = case["event"].clone();
let expected = case["state"].clone();
create_session(child_request(session_id, None)).unwrap();
struct Cleanup<'a>(&'a str);
impl Drop for Cleanup<'_> {
fn drop(&mut self) {
// log-exempt: Expected absence if the test already disposed this process-free session.
let _ = destroy_session(SessionRequest {
session_id: self.0.into(),
});
}
}
let _cleanup = Cleanup(session_id);
receive_messages(
session_id,
vec![response_message(1, "initialize", json!({}))],
);
let launching = launch(LaunchRequest {
session_id: session_id.into(),
operation_id: "launch".into(),
configuration: DebugLaunchConfiguration {
name: "native target".into(),
request: DebugRequestKind::Launch,
arguments: Map::new(),
stepping_filters: None,
},
})
.unwrap();
let frame = decode_frame(&launching.outbound_frames[0]);
let stopped = receive_messages(session_id, vec![event]);
assert_eq!(serde_json::to_value(stopped.state).unwrap(), expected);
// LLDB can report the first stop before acknowledging the launch request.
let acknowledged = receive_messages(
session_id,
vec![response_message(
frame["seq"].as_i64().unwrap(),
"launch",
json!({}),
)],
);
assert_eq!(
serde_json::to_value(acknowledged.state).unwrap(),
expected,
"a response must not resume or resurrect the target"
);
}
}

#[test]
fn dap_logging_keeps_catalog_identity_and_cancellation_private() {
let adapter = "lldb+private-adapter";
Expand Down
3 changes: 3 additions & 0 deletions rust/lithe-core/src/protocol/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,8 @@ pub enum CoreCommand {
ToolchainsFailureReply,
/// Resolves the nearest machine-local directory pin.
ToolchainsResolvePins,
/// Edits a bounded batch of machine-local directory pins without native writes.
ToolchainsEditPins,
/// Resolves consumer defaults while keeping workspace inheritance symbolic.
ToolchainsResolveConsumerPreferences,
/// Edits one machine-local consumer preference and returns its effective input.
Expand Down Expand Up @@ -879,6 +881,7 @@ impl CoreCommand {
"toolchains.decodeReply" => Some(Self::ToolchainsDecodeReply),
"toolchains.failureReply" => Some(Self::ToolchainsFailureReply),
"toolchains.resolvePins" => Some(Self::ToolchainsResolvePins),
"toolchains.editPins" => Some(Self::ToolchainsEditPins),
"toolchains.resolveConsumerPreferences" => {
Some(Self::ToolchainsResolveConsumerPreferences)
}
Expand Down
3 changes: 3 additions & 0 deletions rust/lithe-core/src/runtime/dispatcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,9 @@ fn execute_parsed(mut parsed: CoreRequest) -> CoreResponse {
CoreCommand::ToolchainsResolvePins => {
plugin_response(id, parsed.payload, crate::toolchain::resolve_pins_command)
}
CoreCommand::ToolchainsEditPins => {
plugin_response(id, parsed.payload, crate::toolchain::edit_pins_command)
}
CoreCommand::ToolchainsResolveConsumerPreferences => plugin_response(
id,
parsed.payload,
Expand Down
108 changes: 108 additions & 0 deletions rust/lithe-core/src/tests/toolchain_pins.rs
Original file line number Diff line number Diff line change
Expand Up @@ -259,3 +259,111 @@ fn toolchain_pins_enforce_collection_and_text_limits_before_resolution() {
"plugin.toolchain.limitExceeded"
);
}

/// Calls the public JSON boundary so an unregistered command cannot pass.
fn edit(payload: Value) -> Value {
serde_json::from_str(&crate::execute_json(
&json!({"id":"pin-edit","command":"toolchains.editPins","payload":payload}).to_string(),
))
.unwrap()
}

#[test]
fn toolchain_pin_edits_preserve_siblings_and_remove_only_the_selected_override() {
let document = local_pins();
let changed = edit(json!({"document":document,"directory":"apps/web","edits":[
{"tool":"java","pin":null},
{"tool":"node","pin":pin("/opt/lithe-fixtures/node")}
]}));
assert_eq!(changed["ok"], true, "{changed}");
assert_eq!(changed["data"]["status"], "ok", "{changed}");
let result = &changed["data"]["edit"]["document"];
assert_eq!(result["toolchains"], document["toolchains"]);
assert_eq!(result["pins"]["."], document["pins"]["."]);
assert_eq!(
result["pins"][".config/tools"],
document["pins"][".config/tools"]
);
assert!(result["pins"]["apps/web"].get("java").is_none());
assert_eq!(
result["pins"]["apps/web"]["node"],
pin("/opt/lithe-fixtures/node")
);
assert_eq!(
resolve(result, "apps/web/src", "java")["sourceDirectory"],
"."
);
}

#[test]
fn toolchain_pin_edits_reject_invalid_batches_without_a_partial_document() {
for edits in [
json!([]),
json!([{"tool":"java","pin":pin("relative")}]),
json!([{"tool":"java","pin":null},{"tool":"java","pin":null}]),
json!([{"tool":"java"}]),
json!([{"tool":"java","pin":null,"extra":true}]),
] {
let response = edit(json!({"document":local_pins(),"directory":".","edits":edits}));
assert_eq!(response["data"]["status"], "rejected", "{response}");
assert!(response["data"].get("edit").is_none(), "{response}");
}
let response = edit(
json!({"document":local_pins(),"directory":"../outside","edits":[{"tool":"java","pin":null}]}),
);
assert_eq!(response["data"]["status"], "rejected", "{response}");
}

#[test]
fn toolchain_pin_edits_prune_empty_containers_and_leave_absent_removals_inert() {
let document =
json!({"version":1,"toolchains":{},"pins":{".":{"java":pin("/opt/lithe-fixtures/java")}}});
let response =
edit(json!({"document":document,"directory":".","edits":[{"tool":"java","pin":null}]}));
assert_eq!(
response["data"]["edit"]["document"],
json!({"version":1,"toolchains":{}})
);
let document = json!({"version":1,"toolchains":{}});
let response =
edit(json!({"document":document,"directory":"src","edits":[{"tool":"node","pin":null}]}));
assert_eq!(response["data"]["edit"]["document"], document);
}

#[test]
fn toolchain_pin_edits_match_the_shared_boundary_fixture() {
let fixture: Value = serde_json::from_str(include_str!(
"../../../../shared/fixtures/toolchain-pin-edit.json"
))
.unwrap();
let response = edit(fixture["request"].clone());
assert_eq!(response["data"]["edit"]["document"], fixture["document"]);
}

#[test]
fn toolchain_pin_edits_enforce_final_collection_limits_and_validate_the_source() {
let mut request =
json!({"document":local_pins(),"directory":".","edits":[{"tool":"java","pin":null}]});
request["document"]["unknown"] = json!(true);
assert_eq!(edit(request)["data"]["status"], "rejected");
let edits: Vec<_> = (0..65)
.map(|index| json!({"tool":format!("tool-{index}"),"pin":null}))
.collect();
let response = edit(json!({"document":local_pins(),"directory":".","edits":edits}));
assert_eq!(
response["data"]["error"]["code"],
"plugin.toolchain.limitExceeded"
);
let tools: serde_json::Map<String, Value> = (0..64)
.map(|index| (format!("tool-{index}"), pin("/fixture/tool")))
.collect();
let document = json!({"version":1,"toolchains":{},"pins":{".":tools}});
let response = edit(
json!({"document":document,"directory":".","edits":[{"tool":"extra","pin":pin("/fixture/extra")}]}),
);
assert_eq!(
response["data"]["error"]["code"],
"plugin.toolchain.limitExceeded"
);
assert!(response["data"].get("edit").is_none());
}
5 changes: 5 additions & 0 deletions rust/lithe-core/src/toolchain/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@ pub fn resolve_pins_command(input: Value) -> Result<Value, CoreError> {
command(input, pins::resolve, "resolution")
}

/// Applies one atomic batch of inert directory-pin edits without native writes.
pub fn edit_pins_command(input: Value) -> Result<Value, CoreError> {
command(input, pins::edit, "edit")
}

/// Resolves inert consumer choices without materializing symbolic inheritance.
pub fn resolve_consumer_preferences_command(input: Value) -> Result<Value, CoreError> {
command(input, preferences::resolve, "preferences")
Expand Down
79 changes: 79 additions & 0 deletions rust/lithe-core/src/toolchain/pins.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,85 @@ pub(crate) fn resolve(input: &Value) -> Validation<Value> {
}
}

/// Edits a bounded batch against one captured document without performing I/O.
/// A null pin removes only that override; invalid batches return no document.
pub(crate) fn edit(input: &Value) -> Validation<Value> {
let request = object(input, "input", &["document", "directory", "edits"])?;
let document = required(request, "document", "input")?;
validate(document)?;
let directory = text(
required(request, "directory", "input")?,
"directory",
MAX_DIRECTORY_BYTES,
)?;
if !valid_directory(directory) {
return Err(invalid(
"directory",
"Expected a normalized workspace-relative directory.",
));
}
let edits = required(request, "edits", "input")?
.as_array()
.ok_or_else(|| invalid("edits", "Expected an array."))?;
count(edits.len(), MAX_TOOLS_PER_DIRECTORY, "edits")?;
if edits.is_empty() {
return Err(invalid("edits", "Expected at least one edit."));
}
let mut seen = std::collections::BTreeSet::new();
let mut updated = document.clone();
for (index, edit) in edits.iter().enumerate() {
let field = format!("edits.{index}");
let edit = object(edit, &field, &["tool", "pin"])?;
let tool = text(
required(edit, "tool", &field)?,
&format!("{field}.tool"),
192,
)?;
if !valid_tool_id(tool) || !seen.insert(tool) {
return Err(invalid(
format!("{field}.tool"),
"Expected a unique valid tool identifier.",
));
}
let pin = required(edit, "pin", &field)?;
if pin.is_null() {
if let Some(pins) = updated.get_mut("pins").and_then(Value::as_object_mut) {
if let Some(tools) = pins.get_mut(directory).and_then(Value::as_object_mut) {
tools.remove(tool);
if tools.is_empty() {
pins.remove(directory);
}
}
if pins.is_empty() {
updated
.as_object_mut()
.expect("validated document")
.remove("pins");
}
}
} else {
validate_pin(pin, &format!("{field}.pin"))?;
let pins = updated
.as_object_mut()
.expect("validated document")
.entry("pins")
.or_insert_with(|| json!({}));
let tools = pins
.as_object_mut()
.expect("validated pins")
.entry(directory.to_string())
.or_insert_with(|| json!({}));
tools
.as_object_mut()
.expect("validated tools")
.insert(tool.to_string(), pin.clone());
}
}
// Enforce document budgets after the entire batch, allowing replacement at capacity.
validate(&updated)?;
Ok(json!({"document":updated}))
}

/// Checks the complete local document, retaining legacy entries and dormant tool IDs.
/// This validation is lexical only; paths are never probed, canonicalized, or launched.
pub(crate) fn validate(document: &Value) -> Validation<()> {
Expand Down
28 changes: 28 additions & 0 deletions shared/contracts/rust-core-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -1585,6 +1585,7 @@ package manager owns the download and Lithe does not infer bytes from logs.
| `toolchains.decodeReply` | Validate an inert toolchain query/reply pair against bound provider identity, generation, declared tools and host-native filesystem evidence; no transport or native I/O |
| `toolchains.failureReply` | Build the JSON-RPC error reply for a query that missed its deadline or found no backend, so the host passes it to `toolchains.decodeReply` like a provider's own error; cancellation has none |
| `toolchains.resolvePins` | Validate a captured local document and find the nearest ancestor directory pin, retaining dormant and legacy entries without probing or writing |
| `toolchains.editPins` | Apply one bounded batch of directory pin edits to a captured local document without writing native files |
| `toolchains.resolveConsumerPreferences` | Validate an inert consumer preference document and resolve one tool's explicit Run, consumer, directory or workspace choice while keeping workspace/consumer inheritance symbolic; no native files or launch authority |
| `toolchains.editConsumerPreferences` | Edit or reset one exact consumer/directory tool preference; return the document and effective input without native writes |
| `toolchains.resolve` | Resolve the fixed/project/global/automatic layers from one generation-bound snapshot, retaining unmet requirements, unavailable providers with their cause, conflicts and terminal fixed-value failures |
Expand Down Expand Up @@ -7015,3 +7016,30 @@ rename) before deleting anything. The result is `{delete, keep, seed}`, the firs
seven days old or of unknown age), `abandoned` and `expired` (deleted). The cache and the
ledger live in the platform cache directory, never in the app bundle or install directory.
The fixture is `shared/fixtures/agent/attachment-retention-v1.json`.

### Atomic local toolchain pin editing

`toolchains.editPins` accepts `{document,directory,edits:[{tool,pin}]}` and returns
`{status:"ok",edit:{document}}`. It uses the v1 local toolchain document and the
same normalized directory, tool ID, pin and size limits as `resolvePins`.
A batch contains 1–64 unique tool IDs. `pin:null` removes that directory's explicit
override (ancestor inheritance remains effective); a pin object replaces it.
Only emptied containers on the edited path are pruned. Legacy `toolchains`,
dormant pins and unrelated directories remain intact. Unknown fields, duplicate
IDs, malformed pins and over-budget documents reject the whole batch with no
partial document. Rejections use the existing `plugin.toolchain.invalidParams`
and `plugin.toolchain.limitExceeded` structured error envelope.

The command performs no I/O and grants no execution authority. The host owns
explicit Save confirmation, byte-baseline conflict detection, cancellation,
ignore-file preparation, atomic native replacement, and re-reading the source.
See `shared/fixtures/toolchain-pin-edit.json` for the cross-client vector.

### Debug launch response ordering

A debug adapter may emit `stopped` or `terminated` before replying to `launch`
or `attach`. The acknowledgement completes that operation; it changes state to
`running` only if the session is still `launching`. It never resumes a paused
target or resurrects a terminated one. See
`shared/fixtures/debug/launch-event-order.json`. Hosts serialize each Core update,
its outbound frames and its UI publication in the order returned by the engine.
25 changes: 25 additions & 0 deletions shared/fixtures/debug/launch-event-order.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"cases": [
{
"id": "late-launch-paused",
"event": {
"type": "event",
"event": "stopped",
"body": {
"reason": "breakpoint",
"threadId": 1
}
},
"state": "paused"
},
{
"id": "late-launch-ended",
"event": {
"type": "event",
"event": "terminated",
"body": {}
},
"state": "terminated"
}
]
}
Loading