All checks were successful
boundary-lint / PR boundary-claim (Lint (push) Has been skipped
boundary-lint / ban-list lint (Lint (push) Successful in 18s
ci / mutation test (broker) (push) Has been skipped
ci / test-health gate (push) Successful in 17s
boundary-lint / duplication-deadline (Layer 1/2) (push) Successful in 19s
Release Publish (Gitea session_helper) / verify-release-tag (push) Successful in 17s
ci / rust release (push) Successful in 2m39s
ci / rust debug (push) Successful in 3m11s
Release Publish (Gitea session_helper) / publish-linux-x86_64 (push) Successful in 4m0s
ci / python (push) Successful in 1m32s
Two independent stability fixes prompted by a macOS Sublime Text crash
investigation. Neither is proven to be the root cause of the user-
reported intermittent malloc abort ("pointer being freed was not
allocated") — that signature predates the v0.7.32 watcher and a
parallel FFI ownership audit found the Rust side clean. But both are
genuine bugs the audit surfaced and both reduce future debugging noise.
1. Local cache watcher leak (sublime/sessions/commands.py)
----------------------------------------------------------
``_stop_local_cache_watcher`` had been defined since the v0.7.32
``feat(sync): PR-C — cross-platform local cache filesystem watcher``
landed but **never called from anywhere**. Because
``_start_local_cache_watcher`` early-returns when a handle already
exists for the cache_key, every plugin reload instantiated a fresh
handle on the Python side while the previous ``WatchEntry``
(containing the live ``RecommendedWatcher``) sat in the Rust
``OnceLock<WatcherRegistry>`` forever — the macOS FSEvents thread,
the Linux inotify thread, or the Windows ReadDirectoryChangesW
thread kept running until the Sublime process itself exited.
Fix: add ``_stop_all_local_cache_watchers()`` and call it from
``sessions_plugin_shutdown`` (which already runs from
``plugin.py::plugin_unloaded``). Each shutdown drains the Python
handle dict, asks Rust to drop each ``WatchEntry``, and clears the
dict. Rust ``stop(handle)`` is idempotent — calling it twice on the
same handle just returns ``false`` the second time.
Three regression tests in ``test_bridge_lifecycle``:
* shutdown stops every queued handle and clears the dict
* Rust-side ABI exception still clears Python state (so the next
plugin load starts from a coherent registry)
* second shutdown call is a no-op (no duplicate ``stop(handle)``)
2. ``local_bridge`` eprintln cascade abort
------------------------------------------
When the parent (Sublime + Python ctypes) dies first, the bridge
subprocess inherits a broken stderr pipe. Three ``eprintln!`` sites
in ``main`` would then panic on EPIPE — and because the workspace
sets ``panic = "abort"``, the process SIGABRT'd, generating a
secondary ``DiagnosticReport`` (``local_bridge-*.ips``) that masked
the upstream Sublime crash report and made post-mortems harder to
read end-to-end.
Fix: replace the three ``eprintln!`` with ``let _ = writeln!(
io::stderr(), ...)`` so EPIPE silently fails through to the
``exit(1)`` that always followed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
154 lines
5.4 KiB
Rust
154 lines
5.4 KiB
Rust
#![cfg_attr(all(windows, not(test)), windows_subsystem = "windows")]
|
|
|
|
//! Entry point for the ``local_bridge`` binary.
|
|
//!
|
|
//! The bulk of the bridge logic lives in sibling modules:
|
|
//!
|
|
//! - [`cli`] — argv parsers (top-level forwarder + ``lsp-stdio`` subcommand)
|
|
//! - [`persistent`] — long-lived helper session, dispatcher, broker
|
|
//! - [`lsp_stdio`] — broker-attach LSP relay loop and ``lsp-stdio`` runner
|
|
//! - [`mirror`] — bridge-handled ``mirror-sync`` request
|
|
//!
|
|
//! ``main`` only handles version-banner short-circuiting and the top-level
|
|
//! mode switch (``lsp-stdio`` subcommand vs. forwarder); ``run`` then dispatches
|
|
//! between persistent mode and one-shot request mode.
|
|
|
|
mod cli;
|
|
mod lsp_stdio;
|
|
mod mirror;
|
|
mod persistent;
|
|
|
|
use crate::cli::BridgeCliArgs;
|
|
use crate::lsp_stdio::run_lsp_stdio;
|
|
use crate::persistent::run_persistent;
|
|
use local_bridge::{BridgeCliOutput, BridgeRunError, run_request_over_ssh};
|
|
use session_protocol::RequestEnvelope;
|
|
use std::io::{Read, Write};
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
// Embedded at compile time from Cargo.toml [workspace.package] metadata. The
|
|
// strings end up in the stripped release binary and give EDR / reputation
|
|
// scanners something identifiable to key off when writing allow-rules (see
|
|
// ``SECURITY.md`` for context on why the bridge is flagged by some scanners).
|
|
const LOCAL_BRIDGE_VERSION_BANNER: &str = concat!(
|
|
env!("CARGO_PKG_NAME"),
|
|
" ",
|
|
env!("CARGO_PKG_VERSION"),
|
|
" — ",
|
|
env!("CARGO_PKG_DESCRIPTION"),
|
|
"\nHomepage: ",
|
|
env!("CARGO_PKG_HOMEPAGE"),
|
|
"\nAuthors: ",
|
|
env!("CARGO_PKG_AUTHORS"),
|
|
);
|
|
|
|
fn main() {
|
|
let args: Vec<String> = std::env::args().skip(1).collect();
|
|
if args
|
|
.first()
|
|
.map(String::as_str)
|
|
.is_some_and(|first| matches!(first, "--version" | "-V" | "version"))
|
|
{
|
|
println!("{LOCAL_BRIDGE_VERSION_BANNER}");
|
|
return;
|
|
}
|
|
if args.first().map(String::as_str) == Some("lsp-stdio") {
|
|
if let Err(error) = run_lsp_stdio(&args[1..]) {
|
|
// ``eprintln!`` panics on EPIPE (and ``panic = "abort"`` would then
|
|
// SIGABRT the process). When the parent (Sublime + Python ctypes)
|
|
// dies first the bridge inherits a broken stderr pipe, and a
|
|
// secondary abort here only adds a phantom crash report that
|
|
// hides the real upstream failure. Use ``writeln!`` + ``let _``
|
|
// so EPIPE silently fails through to ``exit(1)``.
|
|
let _ = writeln!(std::io::stderr(), "{error}");
|
|
std::process::exit(1);
|
|
}
|
|
return;
|
|
}
|
|
match run(&args) {
|
|
Ok(output) => match serde_json::to_string(&output) {
|
|
Ok(encoded) => {
|
|
println!("{encoded}");
|
|
}
|
|
Err(error) => {
|
|
let _ = writeln!(
|
|
std::io::stderr(),
|
|
"local_bridge output serialization failed: {error}"
|
|
);
|
|
std::process::exit(1);
|
|
}
|
|
},
|
|
Err(error) => {
|
|
let _ = writeln!(std::io::stderr(), "{error}");
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn run(args: &[String]) -> Result<BridgeCliOutput, BridgeRunError> {
|
|
if args.iter().any(|arg| arg == "--persistent") {
|
|
run_persistent(args)?;
|
|
return Ok(BridgeCliOutput {
|
|
ok: true,
|
|
id: None,
|
|
result: None,
|
|
error: None,
|
|
});
|
|
}
|
|
let cli = BridgeCliArgs::parse(args)?;
|
|
// The remote helper lives under the cache dir keyed by the requested
|
|
// helper revision, NOT the compiled-in bridge version. They're usually
|
|
// the same release but they're separate inputs: a stale bridge binary
|
|
// (CARGO_PKG_VERSION baked at build time) talking to a freshly pushed
|
|
// helper revision must look in the helper's cache dir, not its own.
|
|
let default_remote_helper_path = format!(
|
|
"{}/session_helper",
|
|
local_bridge::remote_helper_cache_dir(&cli.revision)
|
|
);
|
|
let remote_helper_path = cli
|
|
.remote_helper_path
|
|
.as_deref()
|
|
.unwrap_or(default_remote_helper_path.as_str());
|
|
|
|
let stdin_payload = read_stdin()?;
|
|
let request: RequestEnvelope = serde_json::from_str(&stdin_payload)?;
|
|
let req_id = request.id.clone();
|
|
match run_request_over_ssh(&cli.host_alias, remote_helper_path, &cli.revision, request) {
|
|
Ok(result) => Ok(BridgeCliOutput {
|
|
ok: true,
|
|
id: Some(req_id),
|
|
result: Some(result),
|
|
error: None,
|
|
}),
|
|
Err(BridgeRunError::HelperError(error)) => Ok(BridgeCliOutput {
|
|
ok: false,
|
|
id: Some(req_id),
|
|
result: None,
|
|
error: Some(error),
|
|
}),
|
|
Err(error) => Err(error),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn write_bridge_output(
|
|
stdout: &Arc<Mutex<std::io::Stdout>>,
|
|
output: &BridgeCliOutput,
|
|
) -> Result<(), BridgeRunError> {
|
|
let encoded = serde_json::to_string(output)?;
|
|
let mut guard = stdout.lock().unwrap_or_else(|e| e.into_inner());
|
|
writeln!(guard, "{encoded}").map_err(BridgeRunError::Io)?;
|
|
guard.flush().map_err(BridgeRunError::Io)?;
|
|
Ok(())
|
|
}
|
|
|
|
fn read_stdin() -> Result<String, BridgeRunError> {
|
|
let mut buffer = String::new();
|
|
std::io::stdin().read_to_string(&mut buffer)?;
|
|
if buffer.trim().is_empty() {
|
|
return Err(BridgeRunError::HelperLaunchFailed(
|
|
"bridge stdin did not contain a request envelope".to_string(),
|
|
));
|
|
}
|
|
Ok(buffer)
|
|
}
|