Files
sessions/rust/crates/local_bridge/src/main.rs
Myeongseon Choi b44f708892
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
fix(stability): plug local cache watcher leak + stop local_bridge cascade aborts (v0.7.39)
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>
2026-05-03 22:31:58 +09:00

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)
}