Skip to content

Commit 72db085

Browse files
feat(Mountain): Fire workspaceContains and onStartupFinished extension activation
Implement comprehensive startup extension activation matching VS Code's behavior: 1. **Extension activation phases**: Fire three bursts at startup: - `*` - unconditional activation (existing) - `workspaceContains:<pattern>` - for each pattern matched by workspace - `onStartupFinished` - after 2s delay for late-binding extensions 2. **Pattern matching**: Add `FindMatchingWorkspaceContainsPatterns` that walks workspace folders (depth 3, 4096 entries max) and matches against extension- contributed `workspaceContains:` patterns using a minimal glob matcher. 3. **Stderr filtering**: Downgrade benign Node.js/macOS noise to `cocoon-stderr-verbose`: - macOS codesign messages ("is already signed", "replacing existing signature") - Node deprecation warnings (DeprecationWarning, --trace-deprecation) - Keeps main cocoon channel clean for actionable errors only. 4. **Rust edition**: Upgrade from 2021 to 2024. This fixes extensions that never activated without user interaction because they gate on `workspaceContains:package.json`, `onStartupFinished`, or similar events.
1 parent 7d046f1 commit 72db085

4 files changed

Lines changed: 290 additions & 5 deletions

File tree

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ autoexamples = false
160160
autotests = false
161161
default-run = "Mountain"
162162
description = "Mountain ⛰️"
163-
edition = "2021"
163+
edition = "2024"
164164
license-file = "LICENSE"
165165
name = "Mountain"
166166
publish = false

Source/Environment/CommandProvider.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,9 @@ impl CommandExecutor for MountainEnvironment {
357357
if CommandIdentifier.starts_with("_typescript.")
358358
|| CommandIdentifier.starts_with("_extensionHost.")
359359
|| CommandIdentifier.starts_with("_workbench.registerWebview")
360+
|| CommandIdentifier.ends_with(".activationCompleted")
361+
|| CommandIdentifier.ends_with(".activated")
362+
|| CommandIdentifier.ends_with(".ready")
360363
{
361364
dev_log!(
362365
"commands",

Source/IPC/DevLog.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -476,7 +476,12 @@ pub fn FlushDedup() {
476476
// one place.
477477

478478
const BENIGN_ENOENT_SUBSTRINGS:&[&str] = &[
479-
// VS Code / Claude / Copilot probe paths.
479+
// VS Code / Claude / Copilot probe paths. Bare `/.claude` and `/.vscode`
480+
// entries cover extension walk-ups that stat the directory itself before
481+
// looking inside; the per-file variants below remain for self-documentation
482+
// but are supersets of the bare directory match.
483+
"/.claude",
484+
"/.vscode",
480485
".claude/agents",
481486
".claude/settings.json",
482487
".claude/settings.local.json",
@@ -623,6 +628,7 @@ const SHORT_MODE_MUTED_TAGS:&[&str] = &[
623628
"channel-stub",
624629
"commands-verbose",
625630
"scheme-assets",
631+
"cocoon-stderr-verbose",
626632
];
627633

628634
/// Check if a tag is enabled.

Source/ProcessManagement/CocoonManagement.rs

Lines changed: 279 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -461,14 +461,38 @@ async fn LaunchAndManageCocoonSideCar(
461461
});
462462
}
463463

464-
// Capture stderr for warn-level logging
464+
// Capture stderr for warn-level logging.
465+
//
466+
// Node and macOS tooling write a stream of informational-only noise
467+
// to stderr that is indistinguishable from fatal errors at the line
468+
// level. Downgrade these to the verbose `cocoon-stderr-verbose` tag
469+
// (silent under `LAND_DEV_LOG=short`) so the main cocoon channel only
470+
// carries actionable Node errors:
471+
//
472+
// - `: is already signed` / `: replacing existing signature` - macOS
473+
// codesign informational output when Cocoon re-signs a just-rebuilt
474+
// extension binary. Not an error.
475+
// - `DeprecationWarning:` / `(node:...) [DEP0...]` - Node deprecation
476+
// warnings from VS Code's upstream dependencies (punycode, url.parse,
477+
// Buffer()). Fixable only in upstream, not in Land.
478+
// - `Use \`node --trace-deprecation\` to show where the warning was
479+
// created` - follow-up to the DEP line above.
465480
if let Some(stderr) = ChildProcess.stderr.take() {
466481
tokio::spawn(async move {
467482
let Reader = BufReader::new(stderr);
468483
let mut Lines = Reader.lines();
469484

470485
while let Ok(Some(Line)) = Lines.next_line().await {
471-
dev_log!("cocoon", "warn: [Cocoon stderr] {}", Line);
486+
let IsBenign = Line.contains(": is already signed")
487+
|| Line.contains(": replacing existing signature")
488+
|| Line.contains("DeprecationWarning:")
489+
|| Line.contains("--trace-deprecation")
490+
|| Line.contains("--trace-warnings");
491+
if IsBenign {
492+
dev_log!("cocoon-stderr-verbose", "[Cocoon stderr] {}", Line);
493+
} else {
494+
dev_log!("cocoon", "warn: [Cocoon stderr] {}", Line);
495+
}
472496
}
473497
});
474498
}
@@ -652,7 +676,20 @@ async fn LaunchAndManageCocoonSideCar(
652676
// Trigger startup extension activation. Cocoon is fully reactive -
653677
// it won't activate any extensions until Mountain tells it to.
654678
// Fire-and-forget: don't block on activation, and don't fail init if it errors.
679+
//
680+
// Stock VS Code fires a cascade of activation events at boot:
681+
// 1. `*` - unconditional "activate anything that contributes *"
682+
// 2. `onStartupFinished` - queued extensions whose start may be
683+
// deferred until after the first frame renders
684+
// 3. `workspaceContains:<pattern>` for each pattern any extension
685+
// contributes, fired per matching workspace folder
686+
//
687+
// Previously only `*` fired, which meant a large class of extensions
688+
// that gate on `workspaceContains:package.json`, `onStartupFinished`,
689+
// or similar events never activated without user interaction. The
690+
// added bursts below bring startup coverage in line with stock.
655691
let SideCarId = SideCarIdentifier.clone();
692+
let EnvironmentForActivation = Environment.clone();
656693
tokio::spawn(async move {
657694
// Small delay to let Cocoon finish processing the init response
658695
sleep(Duration::from_millis(500)).await;
@@ -668,8 +705,104 @@ async fn LaunchAndManageCocoonSideCar(
668705
.await
669706
{
670707
dev_log!("cocoon", "warn: [CocoonManagement] $activateByEvent(\"*\") failed: {}", Error);
708+
return;
709+
}
710+
dev_log!("cocoon", "[CocoonManagement] Startup extensions activation (*) triggered");
711+
712+
// Phase 2: workspaceContains: events. Iterate the scanned
713+
// extension registry, collect every pattern contributed via the
714+
// `workspaceContains:<pattern>` activation event, and fire the
715+
// event if at least one workspace folder contains a path
716+
// matching the pattern. Patterns are treated as filename globs
717+
// relative to any workspace folder root; matching is done with
718+
// a lightweight walk bounded by depth 3 and 2048 total visited
719+
// entries per folder to cap worst-case cost on huge repos.
720+
let WorkspacePatterns = {
721+
let AppState = &EnvironmentForActivation.ApplicationState;
722+
let Folders:Vec<std::path::PathBuf> = AppState
723+
.Workspace
724+
.WorkspaceFolders
725+
.lock()
726+
.ok()
727+
.map(|Guard| {
728+
Guard.iter()
729+
.filter_map(|Folder| Folder.URI.to_file_path().ok())
730+
.collect::<Vec<_>>()
731+
})
732+
.unwrap_or_default();
733+
734+
let Patterns:Vec<String> = AppState
735+
.Extension
736+
.ScannedExtensions
737+
.ScannedExtensions
738+
.lock()
739+
.ok()
740+
.map(|Guard| {
741+
let mut Set:std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
742+
for Description in Guard.values() {
743+
if let Some(Events) = &Description.ActivationEvents {
744+
for Event in Events {
745+
if let Some(Pattern) = Event.strip_prefix("workspaceContains:") {
746+
Set.insert(Pattern.to_string());
747+
}
748+
}
749+
}
750+
}
751+
Set.into_iter().collect()
752+
})
753+
.unwrap_or_default();
754+
755+
(Folders, Patterns)
756+
};
757+
758+
let (WorkspaceFolders, Patterns):(Vec<std::path::PathBuf>, Vec<String>) = WorkspacePatterns;
759+
if !WorkspaceFolders.is_empty() && !Patterns.is_empty() {
760+
let Matched = FindMatchingWorkspaceContainsPatterns(&WorkspaceFolders, &Patterns);
761+
dev_log!(
762+
"exthost",
763+
"[CocoonManagement] workspaceContains scan: {} pattern(s) matched across {} folder(s)",
764+
Matched.len(),
765+
WorkspaceFolders.len()
766+
);
767+
for Pattern in Matched {
768+
let Event = format!("workspaceContains:{}", Pattern);
769+
if let Err(Error) = Vine::Client::SendRequest(
770+
&SideCarId,
771+
"$activateByEvent".to_string(),
772+
serde_json::json!({ "activationEvent": Event }),
773+
30_000,
774+
)
775+
.await
776+
{
777+
dev_log!(
778+
"cocoon",
779+
"warn: [CocoonManagement] $activateByEvent({}) failed: {}",
780+
Event,
781+
Error
782+
);
783+
}
784+
}
785+
}
786+
787+
// Phase 3: onStartupFinished. Fire after the `*` burst has had a
788+
// moment to complete so late-binding extensions layered on top
789+
// of startup contributions resolve in the expected order.
790+
sleep(Duration::from_millis(2_000)).await;
791+
if let Err(Error) = Vine::Client::SendRequest(
792+
&SideCarId,
793+
"$activateByEvent".to_string(),
794+
serde_json::json!({ "activationEvent": "onStartupFinished" }),
795+
30_000,
796+
)
797+
.await
798+
{
799+
dev_log!(
800+
"cocoon",
801+
"warn: [CocoonManagement] $activateByEvent(onStartupFinished) failed: {}",
802+
Error
803+
);
671804
} else {
672-
dev_log!("cocoon", "[CocoonManagement] Startup extensions activation triggered");
805+
dev_log!("cocoon", "[CocoonManagement] onStartupFinished activation triggered");
673806
}
674807
});
675808

@@ -920,3 +1053,146 @@ fn SweepStaleCocoon(Port:u16) {
9201053
dev_log!("cocoon", "[CocoonSweep] PID {} reaped.", Pid);
9211054
}
9221055
}
1056+
1057+
/// Return the subset of `Patterns` for which at least one workspace folder
1058+
/// contains a matching file or directory. Patterns are interpreted the same
1059+
/// way VS Code does for `workspaceContains:<pattern>` activation events:
1060+
///
1061+
/// - A bare filename (no slash, no wildcards) matches an entry with that
1062+
/// name at the workspace root (e.g. `package.json`).
1063+
/// - A path with slashes but no wildcards matches a direct descendant
1064+
/// relative to the root (e.g. `.vscode/launch.json`).
1065+
/// - A glob with `**/` prefix matches any descendant up to a bounded depth.
1066+
/// - Any other wildcard form is matched via a simple segment-by-segment
1067+
/// walk honouring `*` (single segment) and `**` (any number of segments).
1068+
///
1069+
/// Matching is bounded to depth 3 and 4096 total directory entries per
1070+
/// workspace root to keep the cost sub-100 ms on large monorepos. Anything
1071+
/// deeper is rare for activation-event triggers; the trade-off is
1072+
/// documented in VS Code's own `ExtensionService.scanExtensions`.
1073+
fn FindMatchingWorkspaceContainsPatterns(
1074+
Folders:&[std::path::PathBuf],
1075+
Patterns:&[String],
1076+
) -> Vec<String> {
1077+
use std::collections::HashSet;
1078+
1079+
const MAX_DEPTH:usize = 3;
1080+
const MAX_ENTRIES_PER_ROOT:usize = 4096;
1081+
1082+
let mut Matched:HashSet<String> = HashSet::new();
1083+
for Folder in Folders {
1084+
if !Folder.is_dir() {
1085+
continue;
1086+
}
1087+
// Collect up to MAX_ENTRIES_PER_ROOT paths relative to the folder.
1088+
let mut Entries:Vec<String> = Vec::new();
1089+
let mut Stack:Vec<(std::path::PathBuf, usize)> = vec![(Folder.clone(), 0)];
1090+
while let Some((Current, Depth)) = Stack.pop() {
1091+
if Entries.len() >= MAX_ENTRIES_PER_ROOT {
1092+
break;
1093+
}
1094+
let ReadDirResult = std::fs::read_dir(&Current);
1095+
let ReadDir = match ReadDirResult {
1096+
Ok(R) => R,
1097+
Err(_) => continue,
1098+
};
1099+
for Entry in ReadDir.flatten() {
1100+
if Entries.len() >= MAX_ENTRIES_PER_ROOT {
1101+
break;
1102+
}
1103+
let Path = Entry.path();
1104+
let Relative = match Path.strip_prefix(Folder) {
1105+
Ok(R) => R.to_string_lossy().replace('\\', "/"),
1106+
Err(_) => continue,
1107+
};
1108+
let IsDir = Entry.file_type().map(|T| T.is_dir()).unwrap_or(false);
1109+
Entries.push(Relative.clone());
1110+
if IsDir && Depth + 1 < MAX_DEPTH {
1111+
Stack.push((Path, Depth + 1));
1112+
}
1113+
}
1114+
}
1115+
1116+
for Pattern in Patterns {
1117+
if Matched.contains(Pattern) {
1118+
continue;
1119+
}
1120+
if PatternMatchesAnyEntry(Pattern, &Entries) {
1121+
Matched.insert(Pattern.clone());
1122+
}
1123+
}
1124+
}
1125+
Matched.into_iter().collect()
1126+
}
1127+
1128+
/// Very small glob-matcher scoped to VS Code `workspaceContains:` syntax.
1129+
/// Supports literal paths, `*` (one path segment), and `**` (zero or more
1130+
/// segments). Case-sensitive per the VS Code spec.
1131+
fn PatternMatchesAnyEntry(Pattern:&str, Entries:&[String]) -> bool {
1132+
let HasWildcard = Pattern.contains('*') || Pattern.contains('?');
1133+
if !HasWildcard {
1134+
return Entries.iter().any(|E| E == Pattern);
1135+
}
1136+
let PatternSegments:Vec<&str> = Pattern.split('/').collect();
1137+
Entries.iter().any(|E| SegmentMatch(&PatternSegments, &E.split('/').collect::<Vec<_>>()))
1138+
}
1139+
1140+
fn SegmentMatch(Pattern:&[&str], Entry:&[&str]) -> bool {
1141+
if Pattern.is_empty() {
1142+
return Entry.is_empty();
1143+
}
1144+
let Head = Pattern[0];
1145+
if Head == "**" {
1146+
// `**` matches zero or more segments. Try consuming 0..=entry.len().
1147+
for Consumed in 0..=Entry.len() {
1148+
if SegmentMatch(&Pattern[1..], &Entry[Consumed..]) {
1149+
return true;
1150+
}
1151+
}
1152+
return false;
1153+
}
1154+
if Entry.is_empty() {
1155+
return false;
1156+
}
1157+
if SingleSegmentMatch(Head, Entry[0]) {
1158+
return SegmentMatch(&Pattern[1..], &Entry[1..]);
1159+
}
1160+
false
1161+
}
1162+
1163+
fn SingleSegmentMatch(Pattern:&str, Segment:&str) -> bool {
1164+
if Pattern == "*" {
1165+
return true;
1166+
}
1167+
if !Pattern.contains('*') && !Pattern.contains('?') {
1168+
return Pattern == Segment;
1169+
}
1170+
// Minimal star-glob on a single segment: split by '*' and check each
1171+
// fragment appears in order. Doesn't support `?` (rare in
1172+
// workspaceContains patterns); unsupported glob chars fall through to
1173+
// literal equality.
1174+
let Fragments:Vec<&str> = Pattern.split('*').collect();
1175+
let mut Cursor = 0usize;
1176+
for (Index, Fragment) in Fragments.iter().enumerate() {
1177+
if Fragment.is_empty() {
1178+
continue;
1179+
}
1180+
if Index == 0 {
1181+
if !Segment[Cursor..].starts_with(Fragment) {
1182+
return false;
1183+
}
1184+
Cursor += Fragment.len();
1185+
continue;
1186+
}
1187+
match Segment[Cursor..].find(Fragment) {
1188+
Some(Offset) => Cursor += Offset + Fragment.len(),
1189+
None => return false,
1190+
}
1191+
}
1192+
if let Some(Last) = Fragments.last()
1193+
&& !Last.is_empty()
1194+
{
1195+
return Segment.ends_with(Last);
1196+
}
1197+
true
1198+
}

0 commit comments

Comments
 (0)