feat(open): open drawings in the running editor
Double-clicking a drawing started a whole second copy of the app. Now it
lands as a tab in the editor already open, and that window comes forward.
The election is the bind itself: every GUI launch tries a deterministic
per-user loopback port, and the OS grants it to exactly one process and
releases it on any death, SIGKILL included. Nothing to reap, no PID to
probe, no stale lock after a crash. The winner serves the port from an
iced subscription; the loser checks it is talking to a matching editor,
hands the paths over and exits. Every surprise — no answer, a stranger on
the port, a timeout — boots a normal window, i.e. the old behaviour: the
feature can degrade, but it cannot lose the file.
The gate in main is positional, after every headless mode has returned.
The plugin runner is this same binary re-spawning itself, so a flag list
would rot the first time a mode is added; a position cannot.
Handing a drawing over that is already open switches to its tab instead
of loading a second copy, comparing resolved paths so a symlink or a
`..` hop is still recognised as the same file.
`opening` is a single slot that a second open would overwrite, and
on_file_opened drops any result arriving once it is clear — so drawings
handed over at once would silently vanish. They queue in pending_opens,
drained from every path that clears the slot, error and cancel included.
Exec becomes %F so one launch takes the whole selection; the CLI takes a
list and boot fans it out through the same door. --new-instance forces a
separate process; --read-only / --script / --new always get their own.
Not reused: the automation server. It dispatches arbitrary commands and
writes arbitrary paths, and its open replaces the active tab's document
in place — publishing that on a port every local process can reach would
be unforced privilege escalation. Only its JSON-lines framing is shared.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 15:18:16 +03:00
|
|
|
//! Single instance: a double-clicked drawing opens as a tab in the editor that
|
|
|
|
|
//! is already running, instead of starting a second one.
|
|
|
|
|
//!
|
|
|
|
|
//! The election *is* the `bind`. Every GUI launch tries to bind a deterministic
|
|
|
|
|
//! per-user loopback port; the OS grants it to exactly one process and releases
|
|
|
|
|
//! it on any death, `SIGKILL` included. That deletes the whole staleness
|
|
|
|
|
//! category a lock file would carry — no PID to probe for liveness, no PID
|
|
|
|
|
//! recycling, no inode to unlink, nothing to reap after a crash.
|
|
|
|
|
//!
|
|
|
|
|
//! Whoever binds keeps the listener and serves it from an iced subscription.
|
|
|
|
|
//! Whoever fails to bind connects, checks it is talking to a matching editor,
|
|
|
|
|
//! hands the path over, and exits. Every surprise — no answer, a stranger on
|
|
|
|
|
//! the port, a timeout — falls back to booting a normal window, which is the
|
|
|
|
|
//! behaviour from before this module existed. The feature can degrade, but it
|
|
|
|
|
//! cannot lose the file.
|
|
|
|
|
//!
|
|
|
|
|
//! This port speaks exactly two ops, `ping` and `open`. The automation server
|
|
|
|
|
//! in [`crate::app::automation`] is deliberately unreachable from here: it
|
|
|
|
|
//! dispatches arbitrary commands (`run`) and writes arbitrary paths (`save`),
|
|
|
|
|
//! and its own `open` replaces the active tab's document in place — which would
|
|
|
|
|
//! discard the user's unsaved drawing. We reuse that server's line-delimited
|
|
|
|
|
//! JSON framing and nothing else.
|
|
|
|
|
//!
|
|
|
|
|
//! Upgrade path, if the squatted-port stall or the local-RPC surface ever
|
|
|
|
|
//! proves real: an `AF_UNIX` socket under `$XDG_RUNTIME_DIR`. Everything above
|
|
|
|
|
//! the transport survives that swap — build one or the other, never both.
|
|
|
|
|
|
|
|
|
|
use std::io::{BufRead, BufReader, Write};
|
|
|
|
|
use std::net::{Ipv4Addr, SocketAddr, TcpListener, TcpStream};
|
|
|
|
|
use std::path::PathBuf;
|
|
|
|
|
use std::sync::Mutex;
|
|
|
|
|
use std::time::Duration;
|
|
|
|
|
|
|
|
|
|
use serde_json::{json, Value};
|
|
|
|
|
|
|
|
|
|
/// Protocol tag. Bump the suffix on any wire-format change so a running older
|
|
|
|
|
/// editor is recognised as a stranger and both processes degrade cleanly
|
|
|
|
|
/// instead of misreading each other.
|
|
|
|
|
const MAGIC: &str = "OpenCADStudio/si/2";
|
|
|
|
|
|
|
|
|
|
/// Neither end blocks forever. Long enough to cover a busy primary's accept
|
|
|
|
|
/// backlog, short enough that a wedged peer costs a visible pause and not a
|
|
|
|
|
/// hang.
|
|
|
|
|
const IO_TIMEOUT: Duration = Duration::from_secs(2);
|
|
|
|
|
|
|
|
|
|
/// The bound listener, parked between [`claim`] (which runs in `main`, before
|
|
|
|
|
/// iced exists) and [`subscribe`] (which runs inside the iced runtime).
|
|
|
|
|
/// A static is unavoidable: [`iced::Subscription::run`] takes a plain
|
|
|
|
|
/// `fn() -> S`, so the stream builder cannot capture anything.
|
|
|
|
|
static LISTENER: Mutex<Option<TcpListener>> = Mutex::new(None);
|
|
|
|
|
|
|
|
|
|
/// Who we turned out to be.
|
|
|
|
|
pub enum Claim {
|
|
|
|
|
/// We own the port. Keep booting; [`subscribe`] will serve it.
|
|
|
|
|
Primary,
|
|
|
|
|
/// Someone else owns it — an editor, or a stranger. Connected stream.
|
|
|
|
|
Existing(TcpStream),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// What makes two launches "the same editor, for the same user, right here".
|
|
|
|
|
///
|
|
|
|
|
/// All three parts are load-bearing:
|
|
|
|
|
/// * user — loopback is shared across accounts on one machine, so without it
|
|
|
|
|
/// one user's drawing would surface on another user's screen;
|
|
|
|
|
/// * session — the same user on two seats (or an SSH-forwarded display) must
|
|
|
|
|
/// not have files delivered to the other display;
|
|
|
|
|
/// * executable path — otherwise `cargo run` silently hands your test file to
|
|
|
|
|
/// an installed copy, which makes this feature hostile to maintain.
|
|
|
|
|
fn rendezvous_key() -> String {
|
|
|
|
|
let user = std::env::var("USER")
|
|
|
|
|
.or_else(|_| std::env::var("USERNAME"))
|
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
let session = std::env::var("XDG_SESSION_ID")
|
|
|
|
|
.or_else(|_| std::env::var("WAYLAND_DISPLAY"))
|
|
|
|
|
.or_else(|_| std::env::var("DISPLAY"))
|
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
let exe = std::env::current_exe()
|
|
|
|
|
.map(|p| p.to_string_lossy().into_owned())
|
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
format!("{user}|{session}|{exe}")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Deterministic port for this rendezvous key (FNV-1a, folded into a fixed
|
|
|
|
|
/// window).
|
|
|
|
|
///
|
|
|
|
|
/// 29000..31000 sits below every target's ephemeral range (Linux 32768+,
|
|
|
|
|
/// Windows/macOS 49152+), so the OS never hands our port to a transient socket
|
|
|
|
|
/// while the editor is down. A stranger squatting it is still possible; that
|
|
|
|
|
/// costs one [`IO_TIMEOUT`] pause and then a normal boot — a considered trade,
|
|
|
|
|
/// not a magic number.
|
|
|
|
|
fn port_for_user() -> u16 {
|
|
|
|
|
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
|
|
|
|
|
for b in rendezvous_key().bytes().chain(MAGIC.bytes()) {
|
|
|
|
|
h ^= b as u64;
|
|
|
|
|
h = h.wrapping_mul(0x0000_0100_0000_01b3);
|
|
|
|
|
}
|
|
|
|
|
29000 + (h % 2000) as u16
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn addr() -> SocketAddr {
|
|
|
|
|
SocketAddr::from((Ipv4Addr::LOCALHOST, port_for_user()))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Try to become the editor that serves this user's double-clicks.
|
|
|
|
|
///
|
|
|
|
|
/// Binds loopback only — never `0.0.0.0`, which would raise a firewall prompt
|
|
|
|
|
/// on Windows and expose the port to the network.
|
|
|
|
|
pub fn claim() -> Claim {
|
|
|
|
|
match TcpListener::bind(addr()) {
|
|
|
|
|
Ok(l) => {
|
|
|
|
|
*LISTENER.lock().unwrap_or_else(|e| e.into_inner()) = Some(l);
|
|
|
|
|
Claim::Primary
|
|
|
|
|
}
|
|
|
|
|
Err(_) => match TcpStream::connect_timeout(&addr(), IO_TIMEOUT) {
|
|
|
|
|
Ok(s) => Claim::Existing(s),
|
|
|
|
|
// Bound a moment ago, gone now: the holder exited between our bind
|
|
|
|
|
// and our connect. We hold no listener, so this window cannot serve
|
|
|
|
|
// — the next launch binds properly. Self-healing.
|
|
|
|
|
Err(_) => Claim::Primary,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Hand `paths` to the editor on the other end. `true` once it has acknowledged.
|
|
|
|
|
///
|
|
|
|
|
/// Takes the whole selection in one message: `%F` in the desktop entry hands
|
|
|
|
|
/// every double-clicked drawing to a single launch, and forwarding them
|
|
|
|
|
/// together keeps them one unit rather than a race between connections.
|
|
|
|
|
///
|
|
|
|
|
/// Pings first and only discloses the paths to a peer that answers with our own
|
|
|
|
|
/// [`MAGIC`] and rendezvous key, so a stranger — or a hash collision — learns
|
|
|
|
|
/// nothing about what the user is opening.
|
|
|
|
|
pub fn handoff(stream: TcpStream, paths: &[PathBuf]) -> bool {
|
|
|
|
|
let _ = stream.set_read_timeout(Some(IO_TIMEOUT));
|
|
|
|
|
let _ = stream.set_write_timeout(Some(IO_TIMEOUT));
|
|
|
|
|
let Ok(write_half) = stream.try_clone() else {
|
|
|
|
|
return false;
|
|
|
|
|
};
|
|
|
|
|
let mut w = write_half;
|
|
|
|
|
let mut r = BufReader::new(stream);
|
|
|
|
|
|
|
|
|
|
if writeln!(w, "{}", json!({ "op": "ping" })).is_err() || w.flush().is_err() {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
let mut line = String::new();
|
|
|
|
|
if r.read_line(&mut line).is_err() {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
let ack: Value = match serde_json::from_str(&line) {
|
|
|
|
|
Ok(v) => v,
|
|
|
|
|
Err(_) => return false,
|
|
|
|
|
};
|
|
|
|
|
if ack["app"].as_str() != Some(MAGIC) || ack["key"].as_str() != Some(rendezvous_key().as_str())
|
|
|
|
|
{
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Absolute, not canonical: the editor's working directory differs from
|
|
|
|
|
// ours, so a relative argument must be resolved here — but `canonicalize`
|
|
|
|
|
// would demand the file exist (we want the editor's own error message, not
|
|
|
|
|
// a silent boot) and on Windows yields a `\\?\` verbatim path that would
|
|
|
|
|
// land verbatim in the recents list.
|
|
|
|
|
let abs: Vec<String> = paths
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|p| {
|
|
|
|
|
std::path::absolute(p)
|
|
|
|
|
.unwrap_or_else(|_| p.clone())
|
|
|
|
|
.to_string_lossy()
|
|
|
|
|
.into_owned()
|
|
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
let req = json!({ "op": "open", "paths": abs });
|
|
|
|
|
if writeln!(w, "{req}").is_err() || w.flush().is_err() {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
line.clear();
|
|
|
|
|
if r.read_line(&mut line).is_err() {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
let done: Value = match serde_json::from_str(&line) {
|
|
|
|
|
Ok(v) => v,
|
|
|
|
|
Err(_) => return false,
|
|
|
|
|
};
|
|
|
|
|
if done["ok"].as_bool() != Some(true) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// We are the foreground process — the file manager just launched us — so we
|
|
|
|
|
// hold the right to hand that privilege to the editor, which does not.
|
|
|
|
|
// Without this the drawing opens in a window that stays behind whatever the
|
|
|
|
|
// user was looking at, and the double-click reads as "nothing happened".
|
|
|
|
|
#[cfg(windows)]
|
|
|
|
|
if let Some(pid) = ack["pid"].as_u64() {
|
|
|
|
|
unsafe {
|
|
|
|
|
windows_sys::Win32::UI::WindowsAndMessaging::AllowSetForegroundWindow(pid as u32);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Serve the claimed port: every accepted `open` yields its path.
|
|
|
|
|
///
|
|
|
|
|
/// Inert unless [`claim`] returned [`Claim::Primary`] in this process, so a
|
|
|
|
|
/// window that lost the election simply never produces items.
|
|
|
|
|
pub fn subscribe() -> iced::Subscription<PathBuf> {
|
|
|
|
|
// `worker` must stay a plain `fn` — `Subscription::run` keys the
|
|
|
|
|
// subscription's identity off the function pointer, so turning this into a
|
|
|
|
|
// closure would silently stop the listener with no error.
|
|
|
|
|
iced::Subscription::run(worker)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type PathSender = iced::futures::channel::mpsc::Sender<PathBuf>;
|
|
|
|
|
|
|
|
|
|
fn worker() -> impl iced::futures::Stream<Item = PathBuf> {
|
|
|
|
|
iced::stream::channel(8, serve_claimed_port)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn serve_claimed_port(out: PathSender) {
|
|
|
|
|
let listener = LISTENER.lock().unwrap_or_else(|e| e.into_inner()).take();
|
|
|
|
|
let Some(listener) = listener else {
|
|
|
|
|
// Not the primary: park forever rather than end the stream, so iced
|
|
|
|
|
// does not re-run the recipe.
|
|
|
|
|
std::future::pending::<()>().await;
|
|
|
|
|
return;
|
|
|
|
|
};
|
|
|
|
|
// `accept` blocks, so it lives on its own OS thread and only ever reaches
|
|
|
|
|
// the iced runtime through the non-blocking `try_send`.
|
|
|
|
|
std::thread::spawn(move || {
|
|
|
|
|
let mut out = out;
|
|
|
|
|
for stream in listener.incoming().flatten() {
|
|
|
|
|
serve_one(&mut out, stream);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
std::future::pending::<()>().await;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// One connection: a ping, then at most one open.
|
|
|
|
|
fn serve_one(out: &mut PathSender, stream: TcpStream) {
|
|
|
|
|
let _ = stream.set_read_timeout(Some(IO_TIMEOUT));
|
|
|
|
|
let _ = stream.set_write_timeout(Some(IO_TIMEOUT));
|
|
|
|
|
let Ok(write_half) = stream.try_clone() else {
|
|
|
|
|
return;
|
|
|
|
|
};
|
|
|
|
|
let mut w = write_half;
|
|
|
|
|
let mut r = BufReader::new(stream);
|
|
|
|
|
let mut line = String::new();
|
|
|
|
|
loop {
|
|
|
|
|
line.clear();
|
|
|
|
|
if !r.read_line(&mut line).map(|n| n > 0).unwrap_or(false) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
let Ok(req) = serde_json::from_str::<Value>(&line) else {
|
|
|
|
|
return;
|
|
|
|
|
};
|
|
|
|
|
match req["op"].as_str() {
|
|
|
|
|
Some("ping") => {
|
|
|
|
|
let ack = json!({
|
|
|
|
|
"ok": true,
|
|
|
|
|
"app": MAGIC,
|
|
|
|
|
"key": rendezvous_key(),
|
|
|
|
|
"pid": std::process::id(),
|
|
|
|
|
});
|
|
|
|
|
if writeln!(w, "{ack}").is_err() || w.flush().is_err() {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Some("open") => {
|
|
|
|
|
// All-or-nothing: a partial send would silently drop drawings
|
|
|
|
|
// the user selected, which is the failure this whole path
|
|
|
|
|
// exists to avoid.
|
|
|
|
|
let ok = match req["paths"].as_array() {
|
|
|
|
|
Some(a) => a
|
|
|
|
|
.iter()
|
|
|
|
|
.filter_map(|p| p.as_str())
|
|
|
|
|
.all(|p| out.try_send(PathBuf::from(p)).is_ok()),
|
|
|
|
|
None => false,
|
|
|
|
|
};
|
|
|
|
|
let _ = writeln!(w, "{}", json!({ "ok": ok }));
|
|
|
|
|
let _ = w.flush();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
_ => return,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn port_is_deterministic_and_in_the_reserved_window() {
|
|
|
|
|
let a = port_for_user();
|
|
|
|
|
let b = port_for_user();
|
|
|
|
|
assert_eq!(a, b, "same process must always compute the same port");
|
|
|
|
|
assert!(
|
|
|
|
|
(29000..31000).contains(&a),
|
|
|
|
|
"port {a} escaped the reserved window"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn rendezvous_key_carries_user_session_and_exe() {
|
|
|
|
|
// All three parts must be present, or the isolation the key exists for
|
|
|
|
|
// is silently gone.
|
|
|
|
|
let k = rendezvous_key();
|
|
|
|
|
assert_eq!(k.matches('|').count(), 2, "key shape changed: {k:?}");
|
|
|
|
|
let exe = std::env::current_exe().unwrap();
|
|
|
|
|
assert!(
|
|
|
|
|
k.ends_with(&*exe.to_string_lossy()),
|
|
|
|
|
"key must pin the executable: {k:?}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn claim_elects_exactly_one_owner() {
|
|
|
|
|
// Hold the port the way a primary would, then prove a second claim in
|
|
|
|
|
// this process does not also think it is primary.
|
|
|
|
|
let held = match TcpListener::bind(addr()) {
|
|
|
|
|
Ok(l) => l,
|
|
|
|
|
// Another OCS (or a stray test binary) already owns it — the very
|
|
|
|
|
// condition under test cannot be set up, so skip rather than lie.
|
|
|
|
|
Err(_) => return,
|
|
|
|
|
};
|
|
|
|
|
match claim() {
|
|
|
|
|
Claim::Existing(_) => {}
|
|
|
|
|
Claim::Primary => {
|
|
|
|
|
panic!("bind was already held; claim() must not elect a second owner")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
drop(held);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn handoff_refuses_a_stranger_on_the_port() {
|
|
|
|
|
// A squatter that answers something other than our ack must never be
|
|
|
|
|
// told which file the user is opening.
|
|
|
|
|
let listener = match TcpListener::bind((Ipv4Addr::LOCALHOST, 0)) {
|
|
|
|
|
Ok(l) => l,
|
|
|
|
|
Err(_) => return,
|
|
|
|
|
};
|
|
|
|
|
let port = listener.local_addr().unwrap().port();
|
|
|
|
|
let t = std::thread::spawn(move || {
|
|
|
|
|
if let Ok((s, _)) = listener.accept() {
|
|
|
|
|
let mut w = s.try_clone().unwrap();
|
|
|
|
|
let mut r = BufReader::new(s);
|
|
|
|
|
let mut line = String::new();
|
|
|
|
|
let _ = r.read_line(&mut line);
|
|
|
|
|
// Wrong app tag — a different program that happens to be here.
|
|
|
|
|
let _ = writeln!(w, "{}", json!({ "ok": true, "app": "something-else" }));
|
|
|
|
|
let _ = w.flush();
|
|
|
|
|
// Read anything more: if handoff leaked the path, this sees it.
|
|
|
|
|
line.clear();
|
|
|
|
|
let _ = r.read_line(&mut line);
|
|
|
|
|
line
|
|
|
|
|
} else {
|
|
|
|
|
String::new()
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
let s = TcpStream::connect((Ipv4Addr::LOCALHOST, port)).unwrap();
|
|
|
|
|
assert!(
|
|
|
|
|
!handoff(s, &[PathBuf::from("/tmp/secret.dwg")]),
|
|
|
|
|
"handoff must reject a peer that fails the identity check"
|
|
|
|
|
);
|
|
|
|
|
let leaked = t.join().unwrap();
|
|
|
|
|
assert!(
|
|
|
|
|
!leaked.contains("secret.dwg"),
|
|
|
|
|
"path disclosed to a stranger: {leaked:?}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn every_one_of_three_concurrent_handoffs_arrives() {
|
|
|
|
|
// Selecting three drawings launches three processes at once (measured:
|
|
|
|
|
// `%f` + 3 files = 3 spawns, one file each). All three must land.
|
|
|
|
|
let listener = match TcpListener::bind((Ipv4Addr::LOCALHOST, 0)) {
|
|
|
|
|
Ok(l) => l,
|
|
|
|
|
Err(_) => return,
|
|
|
|
|
};
|
|
|
|
|
let port = listener.local_addr().unwrap().port();
|
|
|
|
|
let (tx, mut rx) = iced::futures::channel::mpsc::channel::<PathBuf>(8);
|
|
|
|
|
// The real accept loop, verbatim.
|
|
|
|
|
std::thread::spawn(move || {
|
|
|
|
|
let mut out = tx;
|
|
|
|
|
for stream in listener.incoming().flatten() {
|
|
|
|
|
serve_one(&mut out, stream);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
let senders: Vec<_> = (0..3)
|
|
|
|
|
.map(|i| {
|
|
|
|
|
std::thread::spawn(move || {
|
|
|
|
|
let s = TcpStream::connect((Ipv4Addr::LOCALHOST, port)).unwrap();
|
|
|
|
|
handoff(s, &[PathBuf::from(format!("/tmp/ocs_concurrent_{i}.dwg"))])
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
for (i, t) in senders.into_iter().enumerate() {
|
|
|
|
|
assert!(t.join().unwrap(), "handoff {i} reported failure");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let mut got = Vec::new();
|
|
|
|
|
while let Ok(Some(p)) = rx.try_next() {
|
|
|
|
|
got.push(p.to_string_lossy().into_owned());
|
|
|
|
|
}
|
|
|
|
|
got.sort();
|
|
|
|
|
assert_eq!(got.len(), 3, "expected all three paths, got {got:?}");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn handoff_gives_up_on_a_peer_that_never_answers() {
|
|
|
|
|
// A wedged peer must cost a timeout, not a hang.
|
|
|
|
|
let listener = match TcpListener::bind((Ipv4Addr::LOCALHOST, 0)) {
|
|
|
|
|
Ok(l) => l,
|
|
|
|
|
Err(_) => return,
|
|
|
|
|
};
|
|
|
|
|
let port = listener.local_addr().unwrap().port();
|
|
|
|
|
let t = std::thread::spawn(move || {
|
|
|
|
|
let _held = listener.accept();
|
|
|
|
|
std::thread::sleep(IO_TIMEOUT * 3);
|
|
|
|
|
});
|
|
|
|
|
let s = TcpStream::connect((Ipv4Addr::LOCALHOST, port)).unwrap();
|
2026-07-25 21:34:29 +03:00
|
|
|
let start = iced::time::Instant::now();
|
feat(open): open drawings in the running editor
Double-clicking a drawing started a whole second copy of the app. Now it
lands as a tab in the editor already open, and that window comes forward.
The election is the bind itself: every GUI launch tries a deterministic
per-user loopback port, and the OS grants it to exactly one process and
releases it on any death, SIGKILL included. Nothing to reap, no PID to
probe, no stale lock after a crash. The winner serves the port from an
iced subscription; the loser checks it is talking to a matching editor,
hands the paths over and exits. Every surprise — no answer, a stranger on
the port, a timeout — boots a normal window, i.e. the old behaviour: the
feature can degrade, but it cannot lose the file.
The gate in main is positional, after every headless mode has returned.
The plugin runner is this same binary re-spawning itself, so a flag list
would rot the first time a mode is added; a position cannot.
Handing a drawing over that is already open switches to its tab instead
of loading a second copy, comparing resolved paths so a symlink or a
`..` hop is still recognised as the same file.
`opening` is a single slot that a second open would overwrite, and
on_file_opened drops any result arriving once it is clear — so drawings
handed over at once would silently vanish. They queue in pending_opens,
drained from every path that clears the slot, error and cancel included.
Exec becomes %F so one launch takes the whole selection; the CLI takes a
list and boot fans it out through the same door. --new-instance forces a
separate process; --read-only / --script / --new always get their own.
Not reused: the automation server. It dispatches arbitrary commands and
writes arbitrary paths, and its open replaces the active tab's document
in place — publishing that on a port every local process can reach would
be unforced privilege escalation. Only its JSON-lines framing is shared.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 15:18:16 +03:00
|
|
|
assert!(!handoff(s, &[PathBuf::from("/tmp/a.dwg")]));
|
|
|
|
|
assert!(
|
|
|
|
|
start.elapsed() < IO_TIMEOUT * 2,
|
|
|
|
|
"handoff blocked for {:?} — the read timeout is not applied",
|
|
|
|
|
start.elapsed()
|
|
|
|
|
);
|
|
|
|
|
drop(t);
|
|
|
|
|
}
|
|
|
|
|
}
|