Skip to content
Merged
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
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "eldrun",
"private": true,
"version": "0.1.32",
"version": "0.1.33",
"license": "MIT OR Apache-2.0",
"type": "module",
"scripts": {
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "eldrun"
version = "0.1.32"
version = "0.1.33"
edition = "2021"
license = "MIT OR Apache-2.0"

Expand Down
14 changes: 14 additions & 0 deletions src-tauri/src/commands/git.rs
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,20 @@ fn git_file_statuses_blocking(
}
}

// When `rel_path` is itself wholly ignored, EVERY entry under it is ignored —
// but git's porcelain only ever emits *file* lines, so a child that is an
// empty directory (or a tree of only empty directories, e.g. freshly scaffolded
// experiment-output folders) produces no line at all and would silently drop
// its "ignored" marker, landing in the regular section. git also can't be
// coaxed into listing such dirs: it collapses a wholly-ignored subtree to its
// topmost ignored ancestor and never enumerates empty descendants. Emit a
// sentinel under the reserved key "." (never a real listing entry) so the
// frontend can default every child of this directory to ignored, letting any
// explicit per-child status above (a force-added tracked file) still win.
if wholly_ignored {
map.insert(".".to_string(), "ignored".to_string());
}

Ok(map)
}

Expand Down
50 changes: 50 additions & 0 deletions src-tauri/src/commands/projects.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1017,6 +1017,56 @@ fn patch_compute_hosts(
Ok(hosts)
}

/// Persist which machine shells launched from this project run on — the choice
/// made in the `RunHostPicker`. Mirrors `set_project_python`: it writes BOTH the
/// `projects.json` entry's `extra["run_host"]` (what the frontend seeds its live
/// preference store from on load) and the `project.json` `run_host` field (keeps
/// it with the project). `location` is a `TabLocation` string (`"local"` /
/// `"remote"` / `"host:<id>"`); `None` or empty clears it (back to the primary
/// default). Returns the stored value.
#[tauri::command]
pub fn set_project_run_host(
project_id: String,
location: Option<String>,
) -> Result<Option<String>, String> {
let value = location
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());

let list_path = storage::state_dir().join("projects.json");
let mut list: ProjectsList = if list_path.exists() {
storage::read_json(&list_path).map_err(|e| e.to_string())?
} else {
Vec::new()
};
let entry = list
.iter_mut()
.find(|p| p.id == project_id)
.ok_or_else(|| format!("project '{project_id}' not found"))?;

match &value {
Some(v) => {
entry
.extra
.insert("run_host".into(), serde_json::Value::String(v.clone()));
}
None => {
entry.extra.remove("run_host");
}
}
let local_file = entry.local_file.clone();
storage::write_json(&list_path, &list).map_err(|e| e.to_string())?;

let proj_path = PathBuf::from(&local_file);
if proj_path.exists() {
if let Ok(mut project) = storage::read_json::<Project>(&proj_path) {
project.run_host = value.clone();
storage::write_json(&proj_path, &project).map_err(|e| e.to_string())?;
}
}
Ok(value)
}

/// Add an extra "worker" host to a remote project (the pill's "Add machine").
/// Mints a stable `id` when the incoming spec has none, then appends it. Returns
/// the full updated host list. The primary (`Project.remote`) is untouched.
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -601,6 +601,7 @@ pub fn run() {
commands::projects::add_compute_host,
commands::projects::remove_compute_host,
commands::projects::patch_compute_host,
commands::projects::set_project_run_host,
commands::projects::set_project_categories,
commands::projects::set_project_git_disabled,
commands::projects::save_tab_layout,
Expand Down
9 changes: 9 additions & 0 deletions src-tauri/src/schema/project.rs
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,15 @@ pub struct Project {
/// entry's `extra["python_interpreter"]`.
#[serde(skip_serializing_if = "Option::is_none")]
pub python_interpreter: Option<String>,
/// Which machine shells launched from this project run on — the choice made in
/// the `RunHostPicker`, persisted so it survives a relaunch (a Run/Debug or a
/// new shell tab lands on it instead of the primary). A `TabLocation` string:
/// `"local"` (the mirror), `"remote"` (the primary host), or `"host:<id>"` (a
/// worker). Absent = the shell default (the primary). Mirrored into the
/// `projects.json` entry's `extra["run_host"]`, which is what the frontend
/// seeds its live preference store from on load.
#[serde(skip_serializing_if = "Option::is_none")]
pub run_host: Option<String>,
#[serde(flatten)]
pub extra: HashMap<String, Value>,
}
50 changes: 47 additions & 3 deletions src-tauri/src/services/openvpn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -507,7 +507,7 @@ pub fn openvpn_available() -> bool {
/// encrypted private key.
///
/// Shape: `--config <cfg> [--auth-user-pass <f>] [--askpass <f>] --auth-nocache
/// --writepid <pidfile> --connect-timeout 20 --connect-retry-max 3
/// --writepid <pidfile> --connect-timeout 20 --persist-tun
/// --verb 3 --mute 0`.
pub fn openvpn_args(
config: &str,
Expand Down Expand Up @@ -535,8 +535,27 @@ pub fn openvpn_args(
pidfile.to_string_lossy().into_owned(),
"--connect-timeout".to_string(),
"20".to_string(),
"--connect-retry-max".to_string(),
"3".to_string(),
// NO `--connect-retry-max`. That option is *not* an initial-connect
// guard — it caps reconnect attempts for the tunnel's whole lifetime, so
// the `3` this used to pass meant any mid-session blip (a rekey failure,
// a suspended laptop, a server restart) burned the three retries and
// OpenVPN **exited for good**, leaving the machine's routing changed and
// every SSH/SFTP session on top of it stalled against a peer that would
// never answer. Unlimited (the OpenVPN default) is what makes a tunnel
// survive a blip. The *initial* connect is still bounded — from the
// outside, by `CONNECT_TIMEOUT` + the `child.kill()` on its Err — so a
// doomed connect cannot retry forever behind the user's back.
//
// `--persist-tun` keeps the tun device and its routes across those
// reconnects, which is what lets a connection on top of the tunnel ride
// one out instead of breaking. It also makes the failure mode
// fail-*closed*: while OpenVPN is re-handshaking, traffic is black-holed
// rather than leaking out the physical default route. That is the right
// trade for a VPN, and it is only safe now that the tunnel no longer
// gives up permanently (above) and that a tunnel dying is *detected*
// rather than left showing a green lamp (`stores/vpnStatus`'s drop
// reconcile).
"--persist-tun".to_string(),
// Readiness is detected by watching OpenVPN's output for READY_MARKER,
// so logging must be deterministic no matter what the config says: a
// config's `mute N` suppresses consecutive same-category lines — the
Expand Down Expand Up @@ -2543,6 +2562,31 @@ mod tests {
assert!(args.iter().any(|a| a == "--auth-nocache"));
}

/// A tunnel must survive a blip. `--connect-retry-max` caps reconnects for
/// the tunnel's whole *lifetime*, not just the first connect, so passing it
/// meant a momentary drop exhausted the retries and OpenVPN exited for good
/// — leaving the machine's routing changed and every SSH/SFTP session on top
/// stalled against a peer that would never answer. It must stay absent, with
/// `--persist-tun` holding the device and routes across a reconnect.
#[test]
fn openvpn_args_let_a_dropped_tunnel_reconnect_forever() {
let args = openvpn_args(
"/home/u/work.ovpn",
None,
Some(Path::new("/run/eldrun/openvpn/x.pass")),
Path::new("/run/eldrun/openvpn/x.pid"),
)
.unwrap();
assert!(
!args.iter().any(|a| a == "--connect-retry-max"),
"a retry cap makes a blip kill the tunnel permanently: {args:?}"
);
assert!(args.iter().any(|a| a == "--persist-tun"));
// The initial connect stays bounded by CONNECT_TIMEOUT, not by openvpn.
let ti = args.iter().position(|a| a == "--connect-timeout").unwrap();
assert_eq!(args[ti + 1], "20");
}

/// Readiness is detected by scanning OpenVPN's output for the
/// "Initialization Sequence Completed" marker, so the argv must pin the
/// logging knobs a config could otherwise sabotage it with: a `mute 16`
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"productName": "Eldrun",
"version": "0.1.32",
"version": "0.1.33",
"identifier": "io.github.fseiffarth.eldrun",
"build": {
"frontendDist": "../dist",
Expand Down
145 changes: 145 additions & 0 deletions src/__tests__/DirSizeGuard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
/**
* The file tree's folder sizes must never be able to freeze the app.
*
* They are a display aid — the tree renders fine without them — but for a remote
* project each one is a `du` over SSH, and the tree fires one per visible folder
* at once. When the network under the project goes away (a dropped VPN tunnel,
* a suspended laptop) those calls do not *fail*: a black-holed socket returns no
* error, so each blocks until ssh's keepalive gives up ~45s later, holding a
* backend blocking-pool thread the whole time. Opening one folder of thirty
* subfolders then meant thirty stalled threads and a frozen UI.
*
* These lock the three limits that prevent that, and — just as importantly — the
* cases where the guard must stay out of the way.
*/
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";

import {
DirSizeUnavailable,
FAILURES_TO_OPEN,
MAX_CONCURRENT,
OPEN_MS,
TIMEOUT_MS,
guardedDirSize,
isHostTimeout,
resetDirSizeGuard,
} from "../lib/dirSizeGuard";

const PROJECT = "/home/u/eldrun/projects/demo";

/** A call that never settles — what a black-holed SSH round trip looks like. */
const hangs = () => new Promise<number>(() => {});

beforeEach(() => {
resetDirSizeGuard();
vi.useFakeTimers();
});

afterEach(() => {
vi.useRealTimers();
resetDirSizeGuard();
});

describe("timeout", () => {
it("gives up on a call that never answers instead of hanging forever", async () => {
const settled = vi.fn();
const p = guardedDirSize(PROJECT, hangs).then(settled, settled);
await vi.advanceTimersByTimeAsync(TIMEOUT_MS - 1);
expect(settled).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(2);
await p;
expect(settled).toHaveBeenCalledOnce();
expect(isHostTimeout(settled.mock.calls[0][0])).toBe(true);
});

it("passes a prompt answer straight through", async () => {
await expect(guardedDirSize(PROJECT, () => Promise.resolve(4096))).resolves.toBe(4096);
});
});

describe("concurrency cap", () => {
it("holds calls beyond the cap back rather than dispatching them all at once", async () => {
const started = vi.fn();
for (let i = 0; i < MAX_CONCURRENT + 3; i += 1) {
void guardedDirSize(PROJECT, () => {
started();
return hangs();
}).catch(() => {});
}
await vi.advanceTimersByTimeAsync(0);
// The point: a wide folder must not fire one call per subfolder into a
// network that isn't answering.
expect(started).toHaveBeenCalledTimes(MAX_CONCURRENT);
});

it("hands a freed slot to the next waiter", async () => {
const started = vi.fn();
for (let i = 0; i < MAX_CONCURRENT + 1; i += 1) {
void guardedDirSize(PROJECT, () => {
started();
return hangs();
}).catch(() => {});
}
await vi.advanceTimersByTimeAsync(0);
expect(started).toHaveBeenCalledTimes(MAX_CONCURRENT);
// First in flight times out → its slot goes to the queued call.
await vi.advanceTimersByTimeAsync(TIMEOUT_MS + 1);
expect(started).toHaveBeenCalledTimes(MAX_CONCURRENT + 1);
});
});

describe("circuit breaker", () => {
/** Time out `n` calls back to back. */
async function timeOut(n: number) {
for (let i = 0; i < n; i += 1) {
const p = guardedDirSize(PROJECT, hangs).catch((e) => e);
await vi.advanceTimersByTimeAsync(TIMEOUT_MS + 1);
await p;
}
}

it("refuses instantly once the host has repeatedly failed to answer", async () => {
await timeOut(FAILURES_TO_OPEN);
// This is the whole point: the 30th folder costs nothing at all, instead of
// each one paying the timeout in turn.
const run = vi.fn(hangs);
await expect(guardedDirSize(PROJECT, run)).rejects.toBeInstanceOf(DirSizeUnavailable);
expect(run).not.toHaveBeenCalled();
});

it("does not open on failures that are not timeouts", async () => {
// An unreadable folder answered fine — it says nothing about the host, so it
// must not push the project toward a trip.
for (let i = 0; i < FAILURES_TO_OPEN + 2; i += 1) {
await guardedDirSize(PROJECT, () => Promise.reject(new Error("permission denied"))).catch(
() => {},
);
}
await expect(guardedDirSize(PROJECT, () => Promise.resolve(7))).resolves.toBe(7);
});

it("lets a retry through once the open window elapses", async () => {
await timeOut(FAILURES_TO_OPEN);
await vi.advanceTimersByTimeAsync(OPEN_MS + 1);
await expect(guardedDirSize(PROJECT, () => Promise.resolve(11))).resolves.toBe(11);
});

it("closes again on the first success, so one good answer restores the tree", async () => {
await timeOut(FAILURES_TO_OPEN - 1);
await expect(guardedDirSize(PROJECT, () => Promise.resolve(1))).resolves.toBe(1);
// The earlier timeouts are forgiven — the next timeout starts a fresh count
// rather than tripping the breaker immediately.
const p = guardedDirSize(PROJECT, hangs).catch((e) => e);
await vi.advanceTimersByTimeAsync(TIMEOUT_MS + 1);
await p;
const run = vi.fn(() => Promise.resolve(2));
await expect(guardedDirSize(PROJECT, run)).resolves.toBe(2);
expect(run).toHaveBeenCalledOnce();
});

it("is per project — one dead host does not stop another project's tree", async () => {
await timeOut(FAILURES_TO_OPEN);
await expect(guardedDirSize(PROJECT, hangs)).rejects.toBeInstanceOf(DirSizeUnavailable);
await expect(guardedDirSize("/home/u/other", () => Promise.resolve(99))).resolves.toBe(99);
});
});
40 changes: 40 additions & 0 deletions src/__tests__/ShellScriptRun.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,46 @@ describe("shell script run planning", () => {
});
});

it("runs on the chosen worker machine when a run-host preference is set", () => {
const plan = shellScriptRunPlan({
project: remoteProject,
treeRoot: "/state/simplegnn",
syncSource: "remote",
scriptPath: "/home/alice/simplegnn/train.sh",
interp: "bash",
runHostPref: "host:mlai20",
});

// The chosen machine wins over the browsed side; the script path stays
// project-relative so it resolves against that host's own project root (the
// backend re-cds into the worker's remote_path).
expect(plan).toMatchObject({
cwd: "/home/alice/simplegnn",
scriptRel: "train.sh",
initialInput: "bash 'train.sh'",
location: "host:mlai20",
});
});

it("sends a mirror-browsed script to the chosen remote machine", () => {
// Browsing the local mirror but with a worker chosen: the run still lands on
// the worker, and scriptRel (project-relative) resolves there.
const plan = shellScriptRunPlan({
project: remoteProject,
treeRoot: "/state/simplegnn/mirror",
syncSource: "local",
scriptPath: "/state/simplegnn/mirror/train.sh",
interp: "bash",
runHostPref: "host:mlai20",
});

expect(plan).toMatchObject({
cwd: "/home/alice/simplegnn",
scriptRel: "train.sh",
location: "host:mlai20",
});
});

it("refuses to build bash with an empty script path", () => {
expect(
shellScriptRunPlan({
Expand Down
Loading
Loading