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
79 changes: 63 additions & 16 deletions backend/tests/test_build_sidecar.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import importlib.util
import os
from pathlib import Path

import pytest
Expand All @@ -25,30 +26,76 @@ def _load_script_module():
return module


def test_target_filename_mapping_for_supported_triples():
def test_executable_name_for_unix_targets():
script = _load_script_module()

assert (
script.target_filename("aarch64-apple-darwin")
== "csemInsight-aarch64-apple-darwin"
)
assert (
script.target_filename("x86_64-unknown-linux-gnu")
== "csemInsight-x86_64-unknown-linux-gnu"
)
assert script.executable_name("aarch64-apple-darwin") == "csemInsight"
assert script.executable_name("x86_64-unknown-linux-gnu") == "csemInsight"


def test_target_filename_adds_exe_for_windows_target():
def test_executable_name_adds_exe_for_windows_target():
script = _load_script_module()

assert (
script.target_filename("x86_64-pc-windows-msvc")
== "csemInsight-x86_64-pc-windows-msvc.exe"
)
assert script.executable_name("x86_64-pc-windows-msvc") == "csemInsight.exe"


def test_target_filename_raises_for_unsupported_target():
def test_executable_name_raises_for_unsupported_target():
script = _load_script_module()

with pytest.raises(ValueError):
script.target_filename("armv7-unknown-linux-gnueabihf")
script.executable_name("armv7-unknown-linux-gnueabihf")


class TestStageToResources:
"""The onedir tree is staged where tauri.conf.json's resources point."""

def _make_fake_onedir(self, tmp_path: Path, exe_name: str) -> Path:
source = tmp_path / "dist" / "csemInsight-test-triple"
(source / "_internal").mkdir(parents=True)
(source / "_internal" / "libfoo.so").write_bytes(b"lib")
exe = source / exe_name
exe.write_bytes(b"#!/bin/sh\n")
exe.chmod(0o755)
return source

def test_stages_tree_and_keeps_executable_bit(self, tmp_path):
script = _load_script_module()
source = self._make_fake_onedir(tmp_path, "csemInsight")
repo_root = tmp_path / "repo"

destination = script.stage_to_resources(
repo_root, source, "aarch64-apple-darwin"
)

assert destination == (
repo_root / "frontend" / "src-tauri" / "resources" / "backend"
)
staged_exe = destination / "csemInsight"
assert staged_exe.is_file()
assert (destination / "_internal" / "libfoo.so").is_file()
assert os.access(staged_exe, os.X_OK)

def test_replaces_a_previous_staging(self, tmp_path):
script = _load_script_module()
source = self._make_fake_onedir(tmp_path, "csemInsight")
repo_root = tmp_path / "repo"
stale = (
repo_root / "frontend" / "src-tauri" / "resources" / "backend" / "stale.so"
)
stale.parent.mkdir(parents=True)
stale.write_bytes(b"old")

destination = script.stage_to_resources(
repo_root, source, "aarch64-apple-darwin"
)

assert not (destination / "stale.so").exists()
assert (destination / "csemInsight").is_file()

def test_missing_executable_in_tree_fails(self, tmp_path):
script = _load_script_module()
source = self._make_fake_onedir(tmp_path, "wrong-name")
repo_root = tmp_path / "repo"

with pytest.raises(FileNotFoundError):
script.stage_to_resources(repo_root, source, "aarch64-apple-darwin")
5 changes: 2 additions & 3 deletions frontend/src-tauri/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,5 @@
/target/
/gen/schemas

# binaries
binaries/*
!binaries/.gitkeep
# staged backend (built by scripts/build_sidecar.py)
resources/backend/
Empty file.
42 changes: 29 additions & 13 deletions frontend/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ use tauri_plugin_shell::ShellExt;
/// the address the docs and dev tooling refer to.
const PREFERRED_PORT: u16 = 3354;

/// File name of the frozen backend inside the bundled resources.
#[cfg(windows)]
const BACKEND_EXECUTABLE: &str = "csemInsight.exe";
#[cfg(not(windows))]
const BACKEND_EXECUTABLE: &str = "csemInsight";

/// The backend process this window owns, and the port it was told to use.
#[derive(Default)]
struct Backend {
Expand Down Expand Up @@ -62,23 +68,33 @@ fn start_backend(app: &AppHandle) {
return;
};

// --parent-pid lets the backend shut itself down if this process goes away.
// Killing the child we spawn is not enough: PyInstaller's onefile
// bootloader re-executes itself, so the process actually serving requests
// is a grandchild we hold no handle on. It also covers the cases we cannot
// handle from here at all, such as a crash or a force quit.
let sidecar = match app.shell().sidecar("csemInsight") {
Ok(command) => command.args([
"--port",
&port.to_string(),
"--parent-pid",
&std::process::id().to_string(),
]),
// The backend ships as a PyInstaller onedir tree under the bundle's
// resource dir (see scripts/build_sidecar.py for why not onefile).
let backend_exe = match app.path().resource_dir() {
Ok(dir) => dir.join("backend").join(BACKEND_EXECUTABLE),
Err(err) => {
error!("Could not locate the backend sidecar: {}", err);
error!("Could not resolve the resource directory: {}", err);
return;
}
};
if !backend_exe.is_file() {
error!(
"Backend executable not found at {:?}; was the backend staged \
with scripts/build_sidecar.py before building?",
backend_exe
);
return;
}

// --parent-pid lets the backend shut itself down if this process goes
// away without us killing it: a crash or a force quit, where no exit
// handler runs here.
let sidecar = app.shell().command(&backend_exe).args([
"--port",
&port.to_string(),
"--parent-pid",
&std::process::id().to_string(),
]);

let (mut receiver, child) = match sidecar.spawn() {
Ok(spawned) => spawned,
Expand Down
8 changes: 6 additions & 2 deletions frontend/src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,18 @@
"bundle": {
"publisher": "Yinchu Li",
"active": true,
"targets": ["app"],
"targets": [
"app"
],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"externalBin": ["binaries/csemInsight"]
"resources": {
"resources/backend": "backend"
}
}
}
106 changes: 61 additions & 45 deletions scripts/build_sidecar.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,24 @@
#!/usr/bin/env python3
"""Build and place the platform-specific Tauri sidecar binary."""
"""Build the backend and stage it as a Tauri resource directory.

The backend is frozen with PyInstaller's onedir layout, not onefile.
Onefile extracted a 39MB archive to a fresh temp path on every launch,
which meant macOS re-validated every native library's code signature every
time (the validation cache is keyed by path) -- about 20s of startup per
launch, forever. Onedir installs at a stable path, so that cost is paid
once per install/update and warm launches take about a second.

A directory cannot ship through tauri.conf.json's `externalBin` (that
contract wants a single file), so the tree is staged under
frontend/src-tauri/resources/backend/ and declared in the `resources`
map instead; the shell resolves the executable via resource_dir() at
runtime.
"""

from __future__ import annotations

import argparse
import shutil
import stat
import subprocess
import sys
from pathlib import Path
Expand All @@ -18,23 +31,16 @@
}


def target_filename(target_triple: str) -> str:
"""Return sidecar filename expected by Tauri for a target triple."""
def executable_name(target_triple: str) -> str:
"""Return the backend executable's file name for a target triple."""
if target_triple not in SUPPORTED_TARGET_TRIPLES:
raise ValueError(
f"Unsupported target triple '{target_triple}'. "
f"Supported values: {sorted(SUPPORTED_TARGET_TRIPLES)}"
)

base_name = f"csemInsight-{target_triple}"
if target_triple.endswith("windows-msvc"):
return f"{base_name}.exe"
return base_name


def _pyinstaller_binary_name(target_triple: str) -> str:
"""Return onefile output name used by PyInstaller."""
return f"csemInsight-{target_triple}"
return "csemInsight.exe"
return "csemInsight"


def _run_pyinstaller(repo_root: Path, target_triple: str) -> Path:
Expand All @@ -43,7 +49,6 @@ def _run_pyinstaller(repo_root: Path, target_triple: str) -> Path:
if not entrypoint.exists():
raise FileNotFoundError(f"Backend entrypoint not found: {entrypoint}")

name = _pyinstaller_binary_name(target_triple)
dist_dir = backend_dir / "dist"
work_dir = backend_dir / "build" / f"pyinstaller-{target_triple}"
spec_dir = work_dir / "spec"
Expand Down Expand Up @@ -72,12 +77,12 @@ def _run_pyinstaller(repo_root: Path, target_triple: str) -> Path:
"-m",
"PyInstaller",
"--noconfirm",
"--onefile",
"--onedir",
*exclude_args,
"--name",
name,
"csemInsight",
"--distpath",
str(dist_dir),
str(dist_dir / target_triple),
"--workpath",
str(work_dir),
"--specpath",
Expand All @@ -86,56 +91,67 @@ def _run_pyinstaller(repo_root: Path, target_triple: str) -> Path:
]
subprocess.run(command, cwd=backend_dir, check=True)

output_name = name
if target_triple.endswith("windows-msvc"):
output_name = f"{output_name}.exe"
output_path = dist_dir / output_name

if not output_path.exists():
output_dir = dist_dir / target_triple / "csemInsight"
if not (output_dir / executable_name(target_triple)).exists():
raise FileNotFoundError(
f"PyInstaller completed but output binary is missing: {output_path}"
f"PyInstaller completed but the executable is missing in: {output_dir}"
)
return output_path
return output_dir


def _copy_to_tauri_binaries(
def stage_to_resources(
repo_root: Path,
source_binary: Path,
source_dir: Path,
target_triple: str,
) -> Path:
binaries_dir = repo_root / "frontend" / "src-tauri" / "binaries"
binaries_dir.mkdir(parents=True, exist_ok=True)

destination = binaries_dir / target_filename(target_triple)
shutil.copy2(source_binary, destination)

current_mode = destination.stat().st_mode
destination.chmod(current_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)

"""Copy the onedir tree into the Tauri resources location.

Args:
repo_root: Repository root.
source_dir: PyInstaller onedir output directory.
target_triple: Rust target triple, for the executable name.

Returns:
The staged directory, frontend/src-tauri/resources/backend.

Raises:
FileNotFoundError: If the tree lacks the expected executable.
"""
exe = source_dir / executable_name(target_triple)
if not exe.is_file():
raise FileNotFoundError(f"Backend executable missing in tree: {exe}")

destination = repo_root / "frontend" / "src-tauri" / "resources" / "backend"
if destination.exists():
shutil.rmtree(destination)
destination.parent.mkdir(parents=True, exist_ok=True)
shutil.copytree(source_dir, destination)

staged_exe = destination / executable_name(target_triple)
staged_exe.chmod(staged_exe.stat().st_mode | 0o755)
return destination


def build_sidecar(target_triple: str, repo_root: Path) -> Path:
"""Build sidecar via PyInstaller and copy it into src-tauri/binaries."""
# Validate target triple early for clear errors.
target_filename(target_triple)
"""Freeze the backend and stage it under src-tauri/resources."""
executable_name(target_triple) # Validate the triple early.

source_binary = _run_pyinstaller(repo_root=repo_root, target_triple=target_triple)
return _copy_to_tauri_binaries(
source_dir = _run_pyinstaller(repo_root=repo_root, target_triple=target_triple)
return stage_to_resources(
repo_root=repo_root,
source_binary=source_binary,
source_dir=source_dir,
target_triple=target_triple,
)


def _parse_args(argv: list[str] | None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Build the backend sidecar and copy it to Tauri binaries.",
description="Build the backend and stage it as a Tauri resource.",
)
parser.add_argument(
"--target-triple",
required=True,
help="Rust target triple for which to build the sidecar.",
help="Rust target triple for which to build the backend.",
)
parser.add_argument(
"--repo-root",
Expand All @@ -158,7 +174,7 @@ def main(argv: list[str] | None = None) -> int:
print(f"ERROR: {exc}", file=sys.stderr)
return 1

print(f"Built sidecar: {destination}")
print(f"Staged backend: {destination}")
return 0


Expand Down
Loading