Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 46c6199087 | |||
| 71f371fe85 | |||
| c3b0502297 | |||
| 469acd7e3f | |||
| de6968e85e | |||
| 526c07d4fd | |||
| 316297338b | |||
| ca5f34be99 | |||
| e42e7fa793 | |||
| 28b64c9117 | |||
| c1fed4c206 | |||
| 61d2d442c0 | |||
| 266430d1c0 | |||
| 8c167453de | |||
| 10fe734a30 | |||
| ee999b0513 | |||
| 27b631c89e | |||
| 1b807fc24a | |||
| 11794c4e24 | |||
| f2d999973a | |||
| 10b57c8f60 | |||
| 29aabcfd4f | |||
| b72d82624d | |||
| 3f0e31007f | |||
| ecc6c6979c | |||
| 595d08ba5a | |||
| 9db70593c2 | |||
| 1a34ce75b3 | |||
| f7ba0a29e5 | |||
| e032d540b0 | |||
| 967b30f8e0 |
@@ -28,3 +28,6 @@ Thumbs.db
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Verification screenshots
|
||||
*.png
|
||||
|
||||
@@ -0,0 +1,596 @@
|
||||
# Phase 1: ghostty-web Swap Implementation Plan
|
||||
|
||||
> **Execution:** Use the subagent-driven-development workflow to implement this plan.
|
||||
|
||||
**Goal:** Replace xterm.js with ghostty-web as the terminal emulator frontend — same backend, better VT parsing.
|
||||
|
||||
**Architecture:** ghostty-web is a WASM-compiled port of Ghostty's VT100 parser with xterm.js API compatibility. It ships a UMD build (`ghostty-web.umd.cjs`) that exposes globals via `<script>` tag — matching the project's no-build-step vendoring pattern. The WASM file (`ghostty-vt.wasm`) must be served alongside it. ghostty-web bundles FitAddon natively; SearchAddon, WebLinksAddon, and ImageAddon have no ghostty-web equivalents yet and need compatibility investigation.
|
||||
|
||||
**Tech Stack:** ghostty-web (npm), WASM, vanilla JS (no build step), Python pytest for frontend structural tests
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-05-28-terminal-architecture-redesign.md` (Phase 1 section)
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Research ghostty-web addon compatibility
|
||||
|
||||
**Files:**
|
||||
- Create: `docs/superpowers/plans/ghostty-web-addon-research.md`
|
||||
|
||||
**Context:** ghostty-web claims xterm.js API compatibility and bundles FitAddon natively. But we use four addons: FitAddon, SearchAddon, WebLinksAddon, and ImageAddon. This task determines which work, which don't, and what the fallback plan is.
|
||||
|
||||
**Step 1: Download and inspect the ghostty-web npm package**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cd /tmp && mkdir ghostty-web-research && cd ghostty-web-research
|
||||
npm pack ghostty-web
|
||||
tar xzf ghostty-web-*.tgz
|
||||
ls -la package/
|
||||
ls -la package/dist/
|
||||
```
|
||||
|
||||
Examine the UMD build to understand what globals it exposes:
|
||||
```bash
|
||||
head -100 package/dist/ghostty-web.umd.cjs
|
||||
# Look for: what global name it uses (GhosttyWeb? Terminal? etc.)
|
||||
# Look for: FitAddon export
|
||||
# Look for: init() function export
|
||||
```
|
||||
|
||||
Check the WASM file size:
|
||||
```bash
|
||||
ls -la package/ghostty-vt.wasm
|
||||
ls -la package/dist/ghostty-vt.wasm
|
||||
```
|
||||
|
||||
**Step 2: Check if xterm.js addons are loadable via ghostty-web's `loadAddon()` API**
|
||||
|
||||
Read the UMD build to verify the `loadAddon()` method signature. The xterm.js addon interface is:
|
||||
```javascript
|
||||
// Addon must implement: activate(terminal), dispose()
|
||||
terminal.loadAddon(addonInstance);
|
||||
```
|
||||
|
||||
If ghostty-web implements the same `loadAddon()` contract, existing xterm.js addons (SearchAddon, WebLinksAddon) may work as-is since they only depend on the Terminal API surface.
|
||||
|
||||
**Step 3: Check FitAddon**
|
||||
|
||||
ghostty-web bundles FitAddon natively (confirmed in its AGENTS.md: "Addons (FitAddon)"). Determine:
|
||||
- Is it exported from the UMD build as `FitAddon`?
|
||||
- Does it have the same API: `new FitAddon()`, `.fit()`, `.proposeDimensions()`?
|
||||
|
||||
**Step 4: Check ghostty-web's `init()` requirement**
|
||||
|
||||
ghostty-web requires `await init()` before creating Terminal instances (loads WASM). The UMD build needs:
|
||||
- Does `init()` accept a WASM URL parameter? (needed since we serve from `/vendor/`)
|
||||
- Or does it auto-detect the WASM location relative to the JS file?
|
||||
|
||||
**Step 5: Document findings**
|
||||
|
||||
Write `docs/superpowers/plans/ghostty-web-addon-research.md` with:
|
||||
- What globals the UMD build exposes
|
||||
- FitAddon: works/doesn't, API differences
|
||||
- SearchAddon: can xterm.js addon load via `loadAddon()`? If not, what's the fallback?
|
||||
- WebLinksAddon: same question. Note: ghostty-web may handle URL detection natively.
|
||||
- ImageAddon: same question. Note: this is the least critical — Sixel/iTerm2 image support is a nice-to-have.
|
||||
- `init()` behavior: WASM URL configuration
|
||||
- CSS: does ghostty-web need `xterm.css` or has its own styles?
|
||||
- Any API surface differences found (e.g., `term.write()` accepts Uint8Array? string only?)
|
||||
|
||||
**Step 6: Commit**
|
||||
```bash
|
||||
git add docs/superpowers/plans/ghostty-web-addon-research.md
|
||||
git commit -m "docs: ghostty-web addon compatibility research"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Vendor ghostty-web files
|
||||
|
||||
**Files:**
|
||||
- Create: `muxplex/frontend/vendor/ghostty-web.umd.cjs`
|
||||
- Create: `muxplex/frontend/vendor/ghostty-vt.wasm`
|
||||
- Create: `muxplex/frontend/vendor/ghostty-web.css` (if ghostty-web has its own CSS)
|
||||
|
||||
**Depends on:** Task 1 (need to know exact file names and what to vendor)
|
||||
|
||||
**Step 1: Copy the UMD build and WASM into vendor directory**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
# If not already unpacked from Task 1:
|
||||
cd /tmp/ghostty-web-research || (mkdir -p /tmp/ghostty-web-research && cd /tmp/ghostty-web-research && npm pack ghostty-web && tar xzf ghostty-web-*.tgz)
|
||||
|
||||
# Copy UMD build
|
||||
cp /tmp/ghostty-web-research/package/dist/ghostty-web.umd.cjs muxplex/frontend/vendor/ghostty-web.js
|
||||
|
||||
# Copy WASM file (check both locations)
|
||||
cp /tmp/ghostty-web-research/package/dist/ghostty-vt.wasm muxplex/frontend/vendor/ghostty-vt.wasm \
|
||||
|| cp /tmp/ghostty-web-research/package/ghostty-vt.wasm muxplex/frontend/vendor/ghostty-vt.wasm
|
||||
```
|
||||
|
||||
Note: we rename `ghostty-web.umd.cjs` to `ghostty-web.js` to match the project's `.js` extension convention for vendor files.
|
||||
|
||||
**Step 2: If ghostty-web has CSS, copy it**
|
||||
|
||||
Check if there's a CSS file in the package:
|
||||
```bash
|
||||
find /tmp/ghostty-web-research/package -name "*.css" -type f
|
||||
```
|
||||
|
||||
If found, copy to `muxplex/frontend/vendor/ghostty-web.css`.
|
||||
|
||||
**Step 3: Verify the WASM file is served correctly**
|
||||
|
||||
The Python FastAPI app serves `muxplex/frontend/` as static files. WASM files need the correct MIME type (`application/wasm`). Check if FastAPI/Starlette handles this:
|
||||
|
||||
```bash
|
||||
grep -r "wasm\|mime\|StaticFiles\|static" muxplex/main.py | head -20
|
||||
```
|
||||
|
||||
If the static file server doesn't serve `.wasm` with the correct MIME type, we may need to add a MIME type mapping. Most modern versions of Starlette handle this automatically.
|
||||
|
||||
**Step 4: Verify vendor files are present**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
ls -la muxplex/frontend/vendor/ghostty-web.js
|
||||
ls -la muxplex/frontend/vendor/ghostty-vt.wasm
|
||||
wc -c muxplex/frontend/vendor/ghostty-web.js muxplex/frontend/vendor/ghostty-vt.wasm
|
||||
```
|
||||
|
||||
Expected: `ghostty-web.js` exists, `ghostty-vt.wasm` exists (~404KB).
|
||||
|
||||
**Step 5: Commit**
|
||||
```bash
|
||||
git add muxplex/frontend/vendor/ghostty-web.js muxplex/frontend/vendor/ghostty-vt.wasm
|
||||
# Add CSS too if it exists:
|
||||
# git add muxplex/frontend/vendor/ghostty-web.css
|
||||
git commit -m "vendor: add ghostty-web UMD build and WASM"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Update index.html script tags
|
||||
|
||||
**Files:**
|
||||
- Modify: `muxplex/frontend/index.html`
|
||||
|
||||
**Depends on:** Task 2 (vendor files must exist), Task 1 (know the CSS situation)
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
Add a test to `muxplex/tests/test_frontend_js.py` that verifies the new script tags:
|
||||
|
||||
```python
|
||||
# At the top of the file, add:
|
||||
INDEX_HTML_PATH = pathlib.Path(__file__).parent.parent / "frontend" / "index.html"
|
||||
_INDEX_HTML: str = INDEX_HTML_PATH.read_text()
|
||||
|
||||
|
||||
# Add these tests at the end of the file:
|
||||
|
||||
# -- ghostty-web migration (Phase 1) --
|
||||
|
||||
|
||||
def test_index_html_loads_ghostty_web_js() -> None:
|
||||
"""index.html must load ghostty-web.js vendor script."""
|
||||
assert "ghostty-web.js" in _INDEX_HTML, (
|
||||
"index.html must include a script tag for vendor/ghostty-web.js"
|
||||
)
|
||||
|
||||
|
||||
def test_index_html_no_xterm_js_script() -> None:
|
||||
"""index.html must not load xterm.js (replaced by ghostty-web)."""
|
||||
assert 'src="/vendor/xterm.js"' not in _INDEX_HTML, (
|
||||
"index.html must not load xterm.js — replaced by ghostty-web"
|
||||
)
|
||||
|
||||
|
||||
def test_index_html_no_xterm_addon_fit_script() -> None:
|
||||
"""index.html must not load xterm-addon-fit.js (FitAddon is built into ghostty-web)."""
|
||||
assert "xterm-addon-fit.js" not in _INDEX_HTML, (
|
||||
"index.html must not load xterm-addon-fit.js — FitAddon is built into ghostty-web"
|
||||
)
|
||||
```
|
||||
|
||||
**Step 2: Run tests to verify they fail**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
.venv/bin/python -m pytest muxplex/tests/test_frontend_js.py::test_index_html_loads_ghostty_web_js -xvs
|
||||
```
|
||||
Expected: FAIL — ghostty-web.js not found in index.html yet.
|
||||
|
||||
**Step 3: Update index.html**
|
||||
|
||||
In `muxplex/frontend/index.html`, replace the xterm.js script tags (lines 272-276):
|
||||
|
||||
**Before:**
|
||||
```html
|
||||
<script src="/vendor/xterm.js"></script>
|
||||
<script src="/vendor/xterm-addon-fit.js"></script>
|
||||
<script src="/vendor/xterm-addon-web-links.js"></script>
|
||||
<script src="/vendor/xterm-addon-search.js"></script>
|
||||
<script src="/vendor/addon-image.js"></script>
|
||||
```
|
||||
|
||||
**After:**
|
||||
```html
|
||||
<script src="/vendor/ghostty-web.js"></script>
|
||||
<script src="/vendor/xterm-addon-web-links.js"></script>
|
||||
<script src="/vendor/xterm-addon-search.js"></script>
|
||||
<script src="/vendor/addon-image.js"></script>
|
||||
```
|
||||
|
||||
Note: We keep WebLinksAddon, SearchAddon, and ImageAddon for now. They use the xterm.js addon interface (`loadAddon`). Task 1 research will determine if they're compatible. If not, Task 4 will handle fallbacks. FitAddon is dropped because ghostty-web bundles it natively.
|
||||
|
||||
Also update the CSS link if ghostty-web has its own CSS. If ghostty-web does NOT have a CSS file (it uses canvas rendering, not DOM), remove the xterm.css link:
|
||||
|
||||
**Before:**
|
||||
```html
|
||||
<link rel="stylesheet" href="/vendor/xterm.css" />
|
||||
```
|
||||
|
||||
**After (if ghostty-web has CSS):**
|
||||
```html
|
||||
<link rel="stylesheet" href="/vendor/ghostty-web.css" />
|
||||
```
|
||||
|
||||
**After (if ghostty-web has NO CSS — canvas renderer needs no CSS):**
|
||||
```html
|
||||
<!-- ghostty-web uses canvas rendering, no CSS needed -->
|
||||
```
|
||||
|
||||
**Step 4: Run tests to verify they pass**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
.venv/bin/python -m pytest muxplex/tests/test_frontend_js.py -k "ghostty" -xvs
|
||||
```
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Commit**
|
||||
```bash
|
||||
git add muxplex/frontend/index.html muxplex/tests/test_frontend_js.py
|
||||
git commit -m "feat: update index.html to load ghostty-web instead of xterm.js"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Update terminal.js for ghostty-web API
|
||||
|
||||
**Files:**
|
||||
- Modify: `muxplex/frontend/terminal.js`
|
||||
|
||||
**Depends on:** Task 1 (research results), Task 3 (script tags updated)
|
||||
|
||||
**Context:** ghostty-web is API-compatible with xterm.js, but has two key differences:
|
||||
1. Requires `await init()` before creating Terminal — loads WASM asynchronously
|
||||
2. FitAddon is built-in (import path differs)
|
||||
3. The global name exposed by the UMD build may differ from `window.Terminal`
|
||||
|
||||
The existing code uses:
|
||||
- `new window.Terminal({...})` (line 349)
|
||||
- `new window.FitAddon.FitAddon()` (line 369)
|
||||
- `_term.loadAddon(...)` for WebLinksAddon, SearchAddon, ImageAddon (lines 387-406)
|
||||
- `_term.write(string)` for terminal output
|
||||
- `_term.onData(callback)` for terminal input
|
||||
- `_term.onResize(callback)` for resize events
|
||||
- `_term.dispose()` for cleanup
|
||||
- `_term.parser.registerOscHandler()` for OSC 52 clipboard
|
||||
- `_term.attachCustomKeyEventHandler()` for key interception
|
||||
- `_term.getSelection()` and `_term.onSelectionChange()` for clipboard
|
||||
- `_term.focus()`, `_term.cols`, `_term.rows`
|
||||
|
||||
**Step 1: Add WASM initialization at module load**
|
||||
|
||||
Add a WASM initialization block near the top of `terminal.js` (after the module-level state declarations, around line 38). ghostty-web's UMD build exposes an `init()` function that must be called once before creating any Terminal instance:
|
||||
|
||||
```javascript
|
||||
// --- ghostty-web WASM initialization ---
|
||||
// ghostty-web requires WASM to be loaded before creating Terminal instances.
|
||||
// The UMD build exposes init() on the global (e.g., window.GhosttyWeb.init()).
|
||||
// Call init() once at module load; createTerminal() awaits the result.
|
||||
var _ghosttyReady = null; // Promise that resolves when WASM is loaded
|
||||
|
||||
(function _initGhosttyWasm() {
|
||||
// The exact global depends on the UMD build — Task 1 research identifies this.
|
||||
// Common patterns: window.GhosttyWeb.init(), window.ghosttyWeb.init()
|
||||
// The init() call may accept a WASM URL: init('/vendor/ghostty-vt.wasm')
|
||||
if (typeof window !== 'undefined' && window.GhosttyWeb && window.GhosttyWeb.init) {
|
||||
_ghosttyReady = window.GhosttyWeb.init('/vendor/ghostty-vt.wasm');
|
||||
} else if (typeof window !== 'undefined' && window.init) {
|
||||
_ghosttyReady = window.init('/vendor/ghostty-vt.wasm');
|
||||
} else {
|
||||
_ghosttyReady = Promise.resolve(); // Fallback for test environments
|
||||
}
|
||||
})();
|
||||
```
|
||||
|
||||
**Important:** The exact global name and init() signature depend on Task 1 research findings. Adjust the code above based on what the UMD build actually exposes.
|
||||
|
||||
**Step 2: Update `createTerminal()` — Terminal constructor**
|
||||
|
||||
In the `createTerminal()` function (line 335), update the Terminal constructor reference. ghostty-web's UMD build may expose the Terminal class under a different global:
|
||||
|
||||
**Before (line 349):**
|
||||
```javascript
|
||||
_term = new window.Terminal({
|
||||
```
|
||||
|
||||
**After (adjust global name based on Task 1 research):**
|
||||
```javascript
|
||||
// ghostty-web UMD exposes Terminal on the same global or on GhosttyWeb
|
||||
var TerminalClass = window.GhosttyWeb ? window.GhosttyWeb.Terminal : window.Terminal;
|
||||
_term = new TerminalClass({
|
||||
```
|
||||
|
||||
**Step 3: Update FitAddon instantiation**
|
||||
|
||||
ghostty-web bundles FitAddon. The import path changes:
|
||||
|
||||
**Before (line 369):**
|
||||
```javascript
|
||||
_fitAddon = new window.FitAddon.FitAddon();
|
||||
```
|
||||
|
||||
**After (adjust based on Task 1 research — FitAddon may be on GhosttyWeb or still on window.FitAddon):**
|
||||
```javascript
|
||||
var FitAddonClass = (window.GhosttyWeb && window.GhosttyWeb.FitAddon)
|
||||
? window.GhosttyWeb.FitAddon
|
||||
: (window.FitAddon && window.FitAddon.FitAddon);
|
||||
_fitAddon = new FitAddonClass();
|
||||
```
|
||||
|
||||
**Step 4: Guard addon loading for compatibility**
|
||||
|
||||
The WebLinksAddon, SearchAddon, and ImageAddon use the xterm.js addon interface. If they fail to load with ghostty-web, wrap them in try/catch so the terminal still works:
|
||||
|
||||
**Before (lines 387-406):**
|
||||
```javascript
|
||||
var WebLinksAddon = window.WebLinksAddon && window.WebLinksAddon.WebLinksAddon;
|
||||
if (WebLinksAddon) {
|
||||
_term.loadAddon(new WebLinksAddon(function(event, uri) {
|
||||
window.open(uri, '_blank');
|
||||
}));
|
||||
}
|
||||
|
||||
var SearchAddon = window.SearchAddon && window.SearchAddon.SearchAddon;
|
||||
if (SearchAddon) {
|
||||
_searchAddon = new SearchAddon();
|
||||
_term.loadAddon(_searchAddon);
|
||||
}
|
||||
|
||||
var ImageAddon = window.ImageAddon && window.ImageAddon.ImageAddon;
|
||||
if (ImageAddon) {
|
||||
_term.loadAddon(new ImageAddon());
|
||||
}
|
||||
```
|
||||
|
||||
**After:**
|
||||
```javascript
|
||||
// WebLinksAddon — try loading xterm.js addon; ghostty-web may or may not support it
|
||||
try {
|
||||
var WebLinksAddon = window.WebLinksAddon && window.WebLinksAddon.WebLinksAddon;
|
||||
if (WebLinksAddon) {
|
||||
_term.loadAddon(new WebLinksAddon(function(event, uri) {
|
||||
window.open(uri, '_blank');
|
||||
}));
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('WebLinksAddon not compatible with ghostty-web:', e.message);
|
||||
}
|
||||
|
||||
// SearchAddon — try loading xterm.js addon
|
||||
try {
|
||||
var SearchAddon = window.SearchAddon && window.SearchAddon.SearchAddon;
|
||||
if (SearchAddon) {
|
||||
_searchAddon = new SearchAddon();
|
||||
_term.loadAddon(_searchAddon);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('SearchAddon not compatible with ghostty-web:', e.message);
|
||||
_searchAddon = null;
|
||||
}
|
||||
|
||||
// ImageAddon — try loading xterm.js addon (Sixel, iTerm2 IIP, Kitty graphics)
|
||||
try {
|
||||
var ImageAddon = window.ImageAddon && window.ImageAddon.ImageAddon;
|
||||
if (ImageAddon) {
|
||||
_term.loadAddon(new ImageAddon());
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('ImageAddon not compatible with ghostty-web:', e.message);
|
||||
}
|
||||
```
|
||||
|
||||
**Step 5: Handle the async init() in openTerminal()**
|
||||
|
||||
The `openTerminal()` function (line 453) and `switchTerminal()` function (line 642) call `createTerminal()` synchronously. Since ghostty-web requires WASM to be loaded first, ensure `_ghosttyReady` is resolved before terminal creation. The simplest approach: `init()` fires at page load (module execution), so by the time the user clicks a session tile, WASM is loaded. But add a guard:
|
||||
|
||||
In `openTerminal()`, before `createTerminal(fontSize)` (line 479), add:
|
||||
```javascript
|
||||
// Ensure ghostty-web WASM is loaded before creating terminal
|
||||
if (_ghosttyReady && typeof _ghosttyReady.then === 'function') {
|
||||
_ghosttyReady.then(function() {
|
||||
_ghosttyReady = null; // Only wait once
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Note: Since `init()` is called at module load and terminal opening happens after user interaction (seconds later), the WASM will already be loaded. This guard is defensive only.
|
||||
|
||||
**Step 6: Verify the terminal.js changes don't break the existing test suite**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
.venv/bin/python -m pytest muxplex/tests/test_frontend_js.py -xvs
|
||||
```
|
||||
Expected: All existing tests PASS (they test app.js structure, not terminal.js internals).
|
||||
|
||||
**Step 7: Commit**
|
||||
```bash
|
||||
git add muxplex/frontend/terminal.js
|
||||
git commit -m "feat: update terminal.js for ghostty-web API (FitAddon built-in, addon guards)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Remove old xterm.js vendor files
|
||||
|
||||
**Files:**
|
||||
- Delete: `muxplex/frontend/vendor/xterm.js`
|
||||
- Delete: `muxplex/frontend/vendor/xterm.css`
|
||||
- Delete: `muxplex/frontend/vendor/xterm-addon-fit.js`
|
||||
|
||||
**Depends on:** Task 3, Task 4 (new vendor files are loaded, terminal.js updated)
|
||||
|
||||
**Important:** Do NOT delete `xterm-addon-web-links.js`, `xterm-addon-search.js`, or `addon-image.js` yet. These addons are still loaded (with try/catch guards) and may work with ghostty-web's `loadAddon()` interface. They'll be removed later once ghostty-web has native equivalents or Phase 2 eliminates the need.
|
||||
|
||||
**Step 1: Delete the replaced files**
|
||||
|
||||
```bash
|
||||
rm muxplex/frontend/vendor/xterm.js
|
||||
rm muxplex/frontend/vendor/xterm.css
|
||||
rm muxplex/frontend/vendor/xterm-addon-fit.js
|
||||
```
|
||||
|
||||
**Step 2: Verify no remaining references to deleted files**
|
||||
|
||||
```bash
|
||||
grep -r "xterm\.js" muxplex/frontend/ --include="*.html" --include="*.js"
|
||||
grep -r "xterm\.css" muxplex/frontend/ --include="*.html"
|
||||
grep -r "xterm-addon-fit" muxplex/frontend/ --include="*.html" --include="*.js"
|
||||
```
|
||||
|
||||
Expected: No references to `xterm.js`, `xterm.css`, or `xterm-addon-fit.js` in HTML or JS files. (Comments referencing "xterm.js" in code are fine — they're documentation, not imports.)
|
||||
|
||||
**Step 3: Verify remaining vendor files**
|
||||
|
||||
```bash
|
||||
ls -la muxplex/frontend/vendor/
|
||||
```
|
||||
|
||||
Expected contents:
|
||||
- `ghostty-web.js` (new)
|
||||
- `ghostty-vt.wasm` (new)
|
||||
- `ghostty-web.css` (new, if applicable)
|
||||
- `xterm-addon-web-links.js` (kept)
|
||||
- `xterm-addon-search.js` (kept)
|
||||
- `addon-image.js` (kept)
|
||||
- `lit/` directory (unrelated, kept)
|
||||
|
||||
**Step 4: Commit**
|
||||
```bash
|
||||
git add -u muxplex/frontend/vendor/
|
||||
git commit -m "chore: remove xterm.js, xterm.css, xterm-addon-fit.js (replaced by ghostty-web)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Manual smoke test
|
||||
|
||||
**Files:** None (testing only)
|
||||
|
||||
**Depends on:** Tasks 2-5 complete
|
||||
|
||||
**Step 1: Restart the service**
|
||||
|
||||
```bash
|
||||
systemctl --user restart muxplex
|
||||
```
|
||||
|
||||
**Step 2: Test terminal rendering**
|
||||
|
||||
Open the muxplex web UI in a browser. Click a session tile to open a terminal. Verify:
|
||||
- [ ] Terminal renders and shows a shell prompt
|
||||
- [ ] Typing works (keystrokes reach the shell)
|
||||
- [ ] Colors render correctly (run `ls --color` or `htop`)
|
||||
- [ ] Terminal resizes when the browser window resizes (FitAddon working)
|
||||
- [ ] Scrollback works (scroll up to see history)
|
||||
|
||||
**Step 3: Test search (Ctrl+F)**
|
||||
|
||||
Press Ctrl+F in the terminal. If the search bar appears and search works, SearchAddon loaded successfully. If not, note this as a known limitation.
|
||||
|
||||
**Step 4: Test links**
|
||||
|
||||
Run `echo "https://example.com"` in the terminal. Hover over the URL. If it becomes clickable, WebLinksAddon loaded successfully. Note: ghostty-web may handle OSC 8 hyperlinks natively even without WebLinksAddon.
|
||||
|
||||
**Step 5: Test session switching**
|
||||
|
||||
Open one session, then click a different session in the sidebar. Verify the terminal switches and renders the new session content.
|
||||
|
||||
**Step 6: Document any issues**
|
||||
|
||||
If anything doesn't work, note it. Common issues to watch for:
|
||||
- WASM not loading: check browser console for 404 on `ghostty-vt.wasm` or MIME type errors
|
||||
- Terminal blank: check if `init()` resolved before Terminal creation
|
||||
- Addons failing: check console for the try/catch warning messages
|
||||
- CSS issues: ghostty-web uses canvas rendering so xterm.css classes like `.xterm` may not exist
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Run the full test suite
|
||||
|
||||
**Files:** None (testing only)
|
||||
|
||||
**Depends on:** Task 6 (smoke test passed or issues documented)
|
||||
|
||||
**Step 1: Run all tests**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
.venv/bin/python -m pytest muxplex/tests/ -x -q --timeout=30
|
||||
```
|
||||
|
||||
Expected: All 1306 tests pass. The frontend structural tests (`test_frontend_js.py`) verify app.js structure — they don't execute JavaScript, so they're unaffected by the terminal swap. The new tests from Task 3 verify index.html loads ghostty-web.
|
||||
|
||||
**Step 2: If tests fail, fix before proceeding**
|
||||
|
||||
Most likely failure: a test that greps for "xterm" in terminal.js comments. These are documentation tests (the test file checks for patterns in JS source). If a test assumes `xterm.js` is present in terminal.js comments, update the comment to reference ghostty-web instead.
|
||||
|
||||
Check specifically:
|
||||
```bash
|
||||
grep -n "xterm" muxplex/tests/test_frontend_js.py | head -20
|
||||
```
|
||||
|
||||
If any tests reference xterm.js presence in terminal.js, update them.
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Final commit
|
||||
|
||||
**Files:** Any remaining uncommitted changes
|
||||
|
||||
**Depends on:** Task 7 (all tests pass)
|
||||
|
||||
**Step 1: Check for uncommitted changes**
|
||||
|
||||
```bash
|
||||
git status
|
||||
git diff --stat
|
||||
```
|
||||
|
||||
**Step 2: Commit any remaining changes**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "feat: Phase 1 complete — ghostty-web replaces xterm.js as terminal frontend"
|
||||
```
|
||||
|
||||
**Step 3: Verify the commit history**
|
||||
|
||||
```bash
|
||||
git log --oneline -10
|
||||
```
|
||||
|
||||
Expected: Clean commit history showing the incremental swap:
|
||||
1. Research doc
|
||||
2. Vendor ghostty-web files
|
||||
3. Update index.html script tags
|
||||
4. Update terminal.js for API differences
|
||||
5. Remove old xterm.js files
|
||||
6. Final commit (if any remaining changes)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,275 @@
|
||||
# ghostty-web Addon Compatibility Research
|
||||
|
||||
> **Package:** ghostty-web@0.4.0
|
||||
> **Date:** 2026-05-28
|
||||
> **Source:** npm pack + manual inspection of UMD build, ESM build, and TypeScript definitions
|
||||
|
||||
---
|
||||
|
||||
## 1. UMD Build Globals
|
||||
|
||||
The UMD build (`dist/ghostty-web.umd.cjs`, 639KB) uses standard `(function(e){})(this)` wrapping. When loaded via `<script>` tag, the module assigns to `exports` (CommonJS) or `this`. The following symbols are exported:
|
||||
|
||||
| Export Name | Type | Description |
|
||||
|---|---|---|
|
||||
| `Terminal` | class | Main terminal class (xterm.js-compatible API) |
|
||||
| `FitAddon` | class | Bundled FitAddon (activate/dispose/fit/proposeDimensions) |
|
||||
| `Ghostty` | class | WASM wrapper — `Ghostty.load(wasmPath?)` creates instances |
|
||||
| `init` | async function | Convenience initializer — calls `Ghostty.load()` internally |
|
||||
| `getGhostty` | function | Returns the singleton Ghostty instance (internal) |
|
||||
| `CanvasRenderer` | class | Canvas-based terminal renderer |
|
||||
| `EventEmitter` | class | Generic event emitter |
|
||||
| `GhosttyTerminal` | class | Low-level WASM terminal wrapper |
|
||||
| `InputHandler` | class | Keyboard/input event handler |
|
||||
| `KeyEncoder` | class | Key encoding for WASM |
|
||||
| `KeyEncoderOption` | enum | Key encoder options |
|
||||
| `LinkDetector` | class | URL and link detection |
|
||||
| `OSC8LinkProvider` | class | OSC8 hyperlink provider |
|
||||
| `UrlRegexProvider` | class | Regex-based URL provider |
|
||||
| `SelectionManager` | class | Text selection management |
|
||||
| `CellFlags` | enum | Cell style flags (bold, italic, etc.) |
|
||||
|
||||
**Key difference from xterm.js:** The UMD build does NOT expose a single global like `window.Terminal`. It needs a module loader or manual extraction from the exports object. For `<script>` tag loading, the script would need to be wrapped or the page would use `require()` / a UMD shim to access the exports.
|
||||
|
||||
**Recommended approach for vendoring:** Use the UMD file with a small wrapper that extracts `Terminal`, `FitAddon`, and `init` onto `window`:
|
||||
```javascript
|
||||
// In a wrapper or after loading ghostty-web.umd.cjs
|
||||
window.GhosttyWeb = require('ghostty-web'); // or the UMD exports object
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. FitAddon Status: COMPATIBLE (Native)
|
||||
|
||||
ghostty-web bundles FitAddon natively. It is exported directly from the package as `FitAddon`.
|
||||
|
||||
**API surface:**
|
||||
|
||||
| Method | Signature | xterm.js FitAddon Match |
|
||||
|---|---|---|
|
||||
| `activate(terminal)` | `activate(terminal: ITerminalCore): void` | Yes |
|
||||
| `dispose()` | `dispose(): void` | Yes |
|
||||
| `fit()` | `fit(): void` | Yes |
|
||||
| `proposeDimensions()` | `proposeDimensions(): {cols, rows} \| undefined` | Yes |
|
||||
| `observeResize()` | `observeResize(): void` | **Extra** — not in xterm.js FitAddon |
|
||||
|
||||
**Implementation notes:**
|
||||
- Uses `terminal.renderer.getMetrics()` instead of xterm.js's internal `_core._renderService.dimensions` — this means xterm.js's FitAddon won't work with ghostty-web (different internal API), but ghostty-web's native FitAddon works perfectly.
|
||||
- `proposeDimensions()` reads `element.clientWidth/clientHeight` and computes `{cols, rows}` from font metrics — same pattern as xterm.js FitAddon.
|
||||
- Includes a debounced `ResizeObserver` via `observeResize()` — bonus feature not in xterm.js FitAddon.
|
||||
- Minimum dimensions enforced: 2 cols, 1 row.
|
||||
|
||||
**Verdict:** Use ghostty-web's native FitAddon. Drop xterm-addon-fit vendor file.
|
||||
|
||||
---
|
||||
|
||||
## 3. loadAddon() Compatibility
|
||||
|
||||
ghostty-web's `Terminal.loadAddon()` implementation:
|
||||
|
||||
```javascript
|
||||
loadAddon(addon) {
|
||||
addon.activate(this);
|
||||
this.addons.push(addon);
|
||||
}
|
||||
```
|
||||
|
||||
**Interface contract (from TypeScript definitions):**
|
||||
|
||||
```typescript
|
||||
interface ITerminalAddon {
|
||||
activate(terminal: ITerminalCore): void;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
interface ITerminalCore {
|
||||
cols: number;
|
||||
rows: number;
|
||||
element?: HTMLElement;
|
||||
textarea?: HTMLTextAreaElement;
|
||||
}
|
||||
```
|
||||
|
||||
This is a minimal interface. xterm.js addons that only use `cols`, `rows`, `element`, and public API methods (write, onData, etc.) will work. Addons that reach into xterm.js internals (`_core`, `_renderService`, etc.) will break.
|
||||
|
||||
---
|
||||
|
||||
## 4. SearchAddon Compatibility: WILL NOT WORK
|
||||
|
||||
xterm.js SearchAddon (`@xterm/addon-search`) reaches deep into xterm.js internals:
|
||||
- Accesses `terminal._core._bufferService` for buffer traversal
|
||||
- Uses `terminal._core._decorationService` for match highlighting
|
||||
- Relies on xterm.js's internal `IBuffer` / `IBufferLine` with `getCell()` API
|
||||
|
||||
ghostty-web exposes a `buffer` property with `IBufferNamespace` (active, normal, alternate buffers with `getLine()` and `getCell()`), but the internal structure differs from xterm.js — the SearchAddon accesses `_core` which does not exist.
|
||||
|
||||
**Fallback plan:** Implement search natively using ghostty-web's public `buffer` API:
|
||||
- `terminal.buffer.active.getLine(y)` returns `IBufferLine` with `getCell(x).getChars()`
|
||||
- Iterate lines, build text, find matches, use `terminal.select(col, row, length)` to highlight
|
||||
- Our current search usage (Ctrl+F bar with `findNext`/`findPrevious`) can be reimplemented in ~50-80 lines using this public API
|
||||
|
||||
---
|
||||
|
||||
## 5. WebLinksAddon Compatibility: NOT NEEDED
|
||||
|
||||
ghostty-web has **built-in link detection** that replaces xterm.js WebLinksAddon:
|
||||
|
||||
- `LinkDetector` class handles URL detection internally
|
||||
- `UrlRegexProvider` provides regex-based URL detection (same as WebLinksAddon)
|
||||
- `OSC8LinkProvider` handles OSC8 hyperlinks (explicit terminal hyperlinks)
|
||||
- Both providers are registered automatically during `terminal.open()`
|
||||
- Links are underlined on hover and clickable (Ctrl/Cmd+click)
|
||||
|
||||
Additionally, `terminal.registerLinkProvider(provider)` accepts custom link providers with the interface:
|
||||
```typescript
|
||||
interface ILinkProvider {
|
||||
provideLinks(y: number, callback: (links: ILink[] | undefined) => void): void;
|
||||
dispose?(): void;
|
||||
}
|
||||
```
|
||||
|
||||
**Verdict:** Drop xterm-addon-web-links vendor file. ghostty-web handles this natively.
|
||||
|
||||
---
|
||||
|
||||
## 6. ImageAddon Compatibility: WILL NOT WORK
|
||||
|
||||
xterm.js ImageAddon (`@xterm/addon-image`) provides inline image rendering (Sixel, iTerm2 IIP, Kitty graphics protocol). It hooks into xterm.js's render layer system (`_core._renderService`) to overlay images on the terminal canvas.
|
||||
|
||||
ghostty-web uses its own `CanvasRenderer` with a completely different rendering pipeline (two-pass cell rendering via WASM). There is no render layer plugin system.
|
||||
|
||||
**Current usage assessment:** ImageAddon is loaded optionally in our terminal.js (`if (ImageAddon) { ... }`). It's used for Sixel/iTerm2 inline image rendering. This is a nice-to-have feature, not critical for core terminal functionality.
|
||||
|
||||
**Fallback plan:** Drop ImageAddon for now. Ghostty's native terminal supports Kitty graphics protocol — this may be exposed in future ghostty-web versions. Monitor the ghostty-web repo for graphics protocol support.
|
||||
|
||||
---
|
||||
|
||||
## 7. init() and WASM URL Configuration
|
||||
|
||||
### init() function
|
||||
|
||||
```javascript
|
||||
// Simplified from source
|
||||
let ghosttyInstance = null;
|
||||
async function init() {
|
||||
if (!ghosttyInstance) {
|
||||
ghosttyInstance = await Ghostty.load();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`init()` takes **no parameters**. It calls `Ghostty.load()` with no arguments.
|
||||
|
||||
### Ghostty.load(wasmPath?) — the key function
|
||||
|
||||
```typescript
|
||||
static load(wasmPath?: string): Promise<Ghostty>;
|
||||
```
|
||||
|
||||
**If `wasmPath` is provided:** Loads WASM directly from that path. This is the mechanism for `/vendor/` serving.
|
||||
|
||||
**If `wasmPath` is omitted (default `init()`):** Tries multiple fallback locations in order:
|
||||
1. Data URL with embedded tiny WASM stub (for probing — this is just the WASM header)
|
||||
2. `file://` protocol path (for Node/Bun environments)
|
||||
3. URL relative to the JS file (via `import.meta.url` in ESM)
|
||||
4. `./ghostty-vt.wasm` (relative to page)
|
||||
5. `/ghostty-vt.wasm` (root-relative)
|
||||
|
||||
**Recommended approach for our vendoring pattern:**
|
||||
|
||||
Don't use `init()` — use `Ghostty.load()` directly with an explicit WASM path:
|
||||
|
||||
```javascript
|
||||
const ghostty = await GhosttyWeb.Ghostty.load('/vendor/ghostty-vt.wasm');
|
||||
const term = new GhosttyWeb.Terminal({ ghostty: ghostty });
|
||||
```
|
||||
|
||||
This gives full control over WASM location and avoids the fallback probe chain.
|
||||
|
||||
### WASM file details
|
||||
|
||||
- **File:** `ghostty-vt.wasm`
|
||||
- **Size:** 423KB (413KB gzipped estimate: ~180-200KB)
|
||||
- **Duplicated:** Same file at both `package/ghostty-vt.wasm` and `package/dist/ghostty-vt.wasm`
|
||||
- **Content:** Ghostty's VT100 parser compiled to WebAssembly
|
||||
|
||||
---
|
||||
|
||||
## 8. CSS Requirements
|
||||
|
||||
**ghostty-web does NOT ship any CSS files.** No `xterm.css` equivalent exists.
|
||||
|
||||
The terminal renders entirely via `<canvas>` element. All styling (colors, fonts, cursor) is handled through:
|
||||
- Constructor options: `theme`, `fontSize`, `fontFamily`, `cursorStyle`, `cursorBlink`
|
||||
- Canvas rendering in `CanvasRenderer`
|
||||
- Inline styles applied programmatically to the container element
|
||||
|
||||
**Implication:** When swapping from xterm.js, remove the `xterm.css` stylesheet link. No replacement CSS is needed. The terminal container just needs basic CSS for sizing (width/height).
|
||||
|
||||
---
|
||||
|
||||
## 9. API Surface Differences
|
||||
|
||||
### Compatible APIs (same as xterm.js)
|
||||
|
||||
| API | Notes |
|
||||
|---|---|
|
||||
| `new Terminal(options)` | Same options: cols, rows, theme, fontSize, fontFamily, cursorBlink, cursorStyle, scrollback, convertEol, disableStdin, allowTransparency |
|
||||
| `term.open(element)` | Same |
|
||||
| `term.write(data, callback?)` | Accepts both `string` and `Uint8Array` |
|
||||
| `term.writeln(data, callback?)` | Same |
|
||||
| `term.resize(cols, rows)` | Same |
|
||||
| `term.clear()` | Same |
|
||||
| `term.reset()` | Same |
|
||||
| `term.focus()` / `term.blur()` | Same |
|
||||
| `term.dispose()` | Same — also cleans up addons |
|
||||
| `term.loadAddon(addon)` | Same interface |
|
||||
| `term.onData` / `term.onResize` / `term.onTitleChange` / `term.onBell` / `term.onSelectionChange` / `term.onKey` / `term.onScroll` / `term.onRender` / `term.onCursorMove` | Same event API |
|
||||
| `term.cols` / `term.rows` | Same |
|
||||
| `term.element` / `term.textarea` | Same |
|
||||
| `term.buffer` | IBufferNamespace with active/normal/alternate — similar to xterm.js |
|
||||
| `term.getSelection()` / `term.hasSelection()` / `term.clearSelection()` / `term.selectAll()` / `term.select()` | Same |
|
||||
| `term.paste(data)` | Same — handles bracketed paste |
|
||||
| `term.attachCustomKeyEventHandler(handler)` | Same |
|
||||
| `term.registerLinkProvider(provider)` | Same interface |
|
||||
| `term.scrollLines()` / `term.scrollPages()` / `term.scrollToTop()` / `term.scrollToBottom()` | Same |
|
||||
| `term.options` | Proxy object — runtime changes trigger updates (fontSize, fontFamily, cursorBlink, etc.) |
|
||||
| `term.unicode.activeVersion` | Returns "15.1" |
|
||||
|
||||
### Different / Additional APIs
|
||||
|
||||
| API | Difference |
|
||||
|---|---|
|
||||
| `init()` | **New** — must be called before creating Terminal (or pass `ghostty` option) |
|
||||
| `Ghostty.load(wasmPath?)` | **New** — explicit WASM loading with path control |
|
||||
| `new Terminal({ ghostty })` | **New option** — pass pre-loaded Ghostty instance |
|
||||
| `term.input(data, wasUserInput?)` | **New** — input as if typed by user |
|
||||
| `term.renderer` | **Exposed** — CanvasRenderer is public (xterm.js hides this) |
|
||||
| `term.wasmTerm` | **Exposed** — direct access to WASM terminal |
|
||||
| `term.attachCustomWheelEventHandler()` | **New** — custom scroll handling |
|
||||
| `term.smoothScrollTo()` | **New** — animated scrolling |
|
||||
| `FitAddon.observeResize()` | **New** — auto-fit on container resize via ResizeObserver |
|
||||
| `term.getMode()` / `term.hasBracketedPaste()` / `term.hasFocusEvents()` / `term.hasMouseTracking()` | **New** — terminal mode queries |
|
||||
|
||||
### Missing APIs (present in xterm.js, absent in ghostty-web)
|
||||
|
||||
| API | Impact |
|
||||
|---|---|
|
||||
| `term.registerMarker()` | Not available — used by some addons internally |
|
||||
| `term.registerDecoration()` | Not available — used by SearchAddon for highlighting |
|
||||
| `term._core` | Not available — internal access used by many addons |
|
||||
|
||||
---
|
||||
|
||||
## 10. Summary & Recommendations
|
||||
|
||||
| Addon | Status | Action |
|
||||
|---|---|---|
|
||||
| **FitAddon** | Native in ghostty-web | Use `FitAddon` from ghostty-web. Drop `xterm-addon-fit.js`. |
|
||||
| **WebLinksAddon** | Native in ghostty-web | Built-in `LinkDetector` + `UrlRegexProvider`. Drop `xterm-addon-web-links.js`. |
|
||||
| **SearchAddon** | Incompatible | Reimplement using `terminal.buffer` public API + `terminal.select()`. ~50-80 lines. |
|
||||
| **ImageAddon** | Incompatible | Drop for now. Monitor ghostty-web for future graphics protocol support. |
|
||||
|
||||
**Migration blockers:** None. All four addons have a path forward.
|
||||
|
||||
**Critical init change:** Must call `await Ghostty.load('/vendor/ghostty-vt.wasm')` before creating Terminal instances. This is the only breaking change vs xterm.js's synchronous `new Terminal()`.
|
||||
@@ -0,0 +1,282 @@
|
||||
# Terminal Architecture Redesign: muxterm + ghostty-web
|
||||
|
||||
## Goal
|
||||
|
||||
Replace the multi-hop terminal data path (xterm.js → Python WS proxy → ttyd → tmux) with a direct architecture (ghostty-web → muxterm Go binary → tmux) that eliminates the reliability failures and latency inherent in managing ttyd as an external process through a Python relay.
|
||||
|
||||
## Background
|
||||
|
||||
The current terminal data path has fundamental architectural problems causing reliability failures (blank terminals, race conditions, process lifecycle hell) and performance issues (~700ms-1.2s session switch latency, up to 2.5s worst case):
|
||||
|
||||
```
|
||||
Browser (xterm.js) ←WS→ Python FastAPI (dumb byte relay) ←WS→ ttyd ←PTY→ tmux attach
|
||||
```
|
||||
|
||||
Every keystroke and terminal output byte traverses two WebSocket hops through a Python async relay that adds zero value during active use. The relay is a 100% dumb byte pipe — zero inspection, zero logging, zero transformation of frames. It exists solely because Caddy was removed to make muxplex distributable as a single-port Python tool (`uv tool install`), and the Python proxy replaced Caddy's WS forwarding role. Auth became a load-bearing side benefit.
|
||||
|
||||
ttyd is locked to one command per process, forcing a kill/spawn cycle on every session switch. This spawned increasing layers of complexity — process pools, port range allocation (7682-7701), PID file management, terminal instance caches, sleep delays, lsof fallbacks — all patching symptoms of the wrong architecture.
|
||||
|
||||
The "must be pure Python" constraint that drove this design doesn't actually exist. Python packages can include platform-specific compiled binaries via wheels — ruff (Rust), cmake (C++), and the Zig compiler all ship this way via `uv tool install`.
|
||||
|
||||
## Approach
|
||||
|
||||
**Approach A (chosen): Go terminal multiplexer binary, Python keeps the dashboard.**
|
||||
|
||||
A small, purpose-built Go binary (`muxterm`) owns the entire terminal data path. One process, one WebSocket hop, direct PTY ownership via `creack/pty`. Session switching is server-side: muxterm holds a PTY per attached session and routes the active one to the WebSocket. No ttyd. No proxy.
|
||||
|
||||
Python keeps what it's good at: the dashboard API, session enumeration, settings, federation orchestration, poll loop.
|
||||
|
||||
**Rejected alternatives:**
|
||||
|
||||
- **Approach B (Python owns PTYs directly):** Single language, simplest distribution. But Python is still in every byte's path. `asyncio` PTY I/O works but isn't great under heavy output. Signal handling and PTY lifecycle in Python requires careful work. Fighting the language for a systems task.
|
||||
|
||||
- **Approach C (expose ttyd directly, bypass Python):** Eliminates the relay, but ttyd is still one-command-per-process. Session switching still requires process lifecycle management. Moves the complexity rather than eliminating it.
|
||||
|
||||
## Architecture
|
||||
|
||||
Replace the terminal data path only. The Python dashboard/API stays.
|
||||
|
||||
**What changes:** ttyd + Python WS proxy → muxterm (Go binary), xterm.js → ghostty-web
|
||||
**What doesn't change:** Python FastAPI (dashboard, API, settings, federation, session enumeration, poll loop), frontend JS (app.js, sidebar, grid, views, settings, style.css), sessions.py, index.html (SPA shell)
|
||||
|
||||
```
|
||||
UNCHANGED: Browser ←HTTP→ Python FastAPI (dashboard, API, settings, federation)
|
||||
CHANGED: Browser ←WS→ muxterm (Go) ←PTY→ tmux attach
|
||||
```
|
||||
|
||||
Full stack:
|
||||
|
||||
```
|
||||
Browser Server
|
||||
┌──────────────────┐ ┌─────────────────────────────┐
|
||||
│ ghostty-web │ │ muxterm (Go) │
|
||||
│ (WASM parser) │◄──── WS ──► PTY pool (creack/pty) │
|
||||
│ + Canvas render │ │ session routing │
|
||||
│ │ │ control protocol │
|
||||
└──────────────────┘ └─────────────────────────────┘
|
||||
▲
|
||||
┌──────────────────┐ │ process mgmt only
|
||||
│ app.js │ ┌────────┴────────────────────┐
|
||||
│ (dashboard, │◄── HTTP ──►│ Python FastAPI │
|
||||
│ sidebar, grid, │ │ dashboard API, settings, │
|
||||
│ settings) │ │ session enum, federation, │
|
||||
└──────────────────┘ │ auth, poll loop │
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
Python starts muxterm as a managed subprocess on startup (one process, started once). muxterm listens on a localhost TCP port (e.g., 7682 — recycling the old ttyd port). The browser connects directly to muxterm's WebSocket endpoint after obtaining a short-lived auth token from Python. Python is never in the terminal data path — not even for the initial WebSocket handshake.
|
||||
|
||||
## Components
|
||||
|
||||
### muxterm (Go binary)
|
||||
|
||||
muxterm manages a map of sessions → PTYs. Each entry:
|
||||
|
||||
```
|
||||
session "dev-server" → {
|
||||
pty_fd: file descriptor (master side of PTY pair)
|
||||
process: child process handle (tmux attach -t dev-server)
|
||||
size: {cols, rows}
|
||||
}
|
||||
```
|
||||
|
||||
**Lifecycle:**
|
||||
|
||||
- **Attach:** Client requests session → muxterm checks the map. If no entry, calls `pty.Start(exec.Command("tmux", "attach", "-t", session_name))` → stores fd + process. If entry exists and process is alive, reuses it. ~1ms cold start.
|
||||
- **Detach:** When the last WebSocket disconnects from a session, the PTY stays alive (tmux is still attached). Next connection reuses it instantly.
|
||||
- **Cleanup:** On tmux session death (process exits), muxterm removes the map entry and closes the fd. Detected via process wait.
|
||||
|
||||
**Session switching over one WebSocket:** Binary frames for terminal I/O (hot path), text frames for control messages (cold path). When the browser sends an attach command, muxterm swaps which PTY fd the relay goroutines read/write. The old PTY stays alive in the map. Switch time: a pointer swap — microseconds.
|
||||
|
||||
**Size:** ~500-1000 lines of Go. Dependencies: `creack/pty` for PTY management, a WebSocket library (gorilla or nhooyr) for the browser-facing endpoint.
|
||||
|
||||
### ghostty-web (frontend terminal)
|
||||
|
||||
Drop-in replacement for xterm.js. Same `new Terminal()` API. Uses libghostty-vt compiled to WebAssembly — the same VT parser / terminal state machine that runs the native Ghostty desktop terminal. Battle-tested, fuzz-tested, 3+ years of production use.
|
||||
|
||||
**What it provides:** Terminal state management, VT/ANSI/XTERM escape sequence parsing, cursor position tracking, styles, text reflow, scrollback, Unicode/grapheme handling, Kitty keyboard protocol, mouse tracking.
|
||||
|
||||
**What it doesn't provide:** PTY management, networking, WebSocket — those are muxterm's job.
|
||||
|
||||
**Bundle size:** ~400KB WASM.
|
||||
|
||||
### Python FastAPI (unchanged role)
|
||||
|
||||
Keeps all non-terminal responsibilities:
|
||||
- Dashboard API (session list, delete, rename, state)
|
||||
- Settings management
|
||||
- Federation orchestration
|
||||
- Session enumeration and snapshot polling
|
||||
- Auth cookie/bearer/localhost management
|
||||
- Static file serving (SPA)
|
||||
- muxterm process supervision (start, restart on crash)
|
||||
|
||||
## Data Flow
|
||||
|
||||
### Terminal I/O (hot path)
|
||||
|
||||
```
|
||||
Keystroke → ghostty-web → WS binary frame → muxterm → write(pty_fd) → tmux
|
||||
tmux output → read(pty_fd) → muxterm → WS binary frame → ghostty-web → render
|
||||
```
|
||||
|
||||
One hop. Raw bytes. No relay, no framing overhead, no Python in the path.
|
||||
|
||||
### Session switch
|
||||
|
||||
1. User clicks sidebar tile → `app.js` sends `ws.send(JSON.stringify({attach: "dev-server"}))`
|
||||
2. muxterm receives text frame, parses JSON
|
||||
3. muxterm checks PTY map — spawns `tmux attach -t dev-server` if needed (~1ms), reuses existing PTY if present (0ms)
|
||||
4. muxterm sends SIGWINCH to the new PTY → tmux repaints the full screen
|
||||
5. muxterm swaps relay goroutines to the new PTY fd
|
||||
6. muxterm sends `{"attached": "dev-server"}` text frame
|
||||
7. Browser receives confirmation, then full screen repaint arrives as normal binary terminal data
|
||||
8. ghostty-web renders it — user sees current state of the session immediately
|
||||
|
||||
No explicit "screen dump" mechanism. Lean on tmux's native redraw behavior (same as resizing a real terminal).
|
||||
|
||||
### Auth flow
|
||||
|
||||
1. Browser requests `GET /api/terminal-token` from Python FastAPI
|
||||
2. Python runs auth check (cookie/bearer/localhost — same as today)
|
||||
3. If authorized, returns a short-lived HMAC token (30-second TTL, shared secret with muxterm)
|
||||
4. Browser opens WebSocket directly to muxterm's port, including the token
|
||||
5. muxterm validates the HMAC locally (no callback to Python) and accepts the connection
|
||||
6. Python is never in the terminal data path — not even for the initial handshake
|
||||
|
||||
muxterm validates tokens via HMAC with a shared secret configured at startup. No network calls to Python during connection.
|
||||
|
||||
## Wire Protocol
|
||||
|
||||
Two frame types over one WebSocket. WebSocket natively distinguishes text and binary frame types — no additional framing needed.
|
||||
|
||||
### Binary frames — terminal I/O (hot path)
|
||||
|
||||
- **Client → server:** Raw bytes from keyboard, sent directly to active PTY
|
||||
- **Server → client:** Raw bytes from PTY, sent directly to ghostty-web
|
||||
- No type prefix byte, no envelope. Raw bytes in, raw bytes out. Zero overhead per frame.
|
||||
|
||||
### Text frames — control messages (cold path)
|
||||
|
||||
**Client → Server:**
|
||||
|
||||
| Message | Purpose |
|
||||
|---------|---------|
|
||||
| `{"attach": "session-name"}` | Switch active PTY |
|
||||
| `{"resize": {"cols": 120, "rows": 40}}` | Resize active PTY |
|
||||
| `{"detach": true}` | Detach (keep PTY alive) |
|
||||
|
||||
**Server → Client:**
|
||||
|
||||
| Message | Purpose |
|
||||
|---------|---------|
|
||||
| `{"attached": "session-name"}` | Confirm switch complete |
|
||||
| `{"error": "session not found"}` | Attach failed |
|
||||
| `{"exited": "session-name"}` | tmux session died, PTY closed |
|
||||
|
||||
### Design rationale (informed by Zellij)
|
||||
|
||||
Zellij uses two separate WebSocket connections for terminal data and control. They noted this separation "ended up not being necessary" because their application layer already batches messages efficiently. Our single-channel approach with binary/text frame types achieves the same logical separation without managing two connections.
|
||||
|
||||
The SIGWINCH-based screen sync on session switch also comes from studying Zellij's approach — instead of building a screen dump mechanism, leverage tmux's native redraw behavior.
|
||||
|
||||
## Error Handling
|
||||
|
||||
| Failure | Response |
|
||||
|---------|----------|
|
||||
| `tmux attach` fails (session doesn't exist) | Send `{"error": "session not found"}` over control channel. Browser shows toast. |
|
||||
| PTY process exits (tmux session killed) | Send `{"exited": "session-name"}`. Browser returns to dashboard grid. Clean up map entry. |
|
||||
| muxterm crashes | Python detects child exit, restarts muxterm. Browser WS reconnect fires, reattaches. PTYs die with the process, but tmux sessions survive — new PTYs created on reattach. |
|
||||
| WebSocket drops (network) | Browser reconnects, sends `{"attach": "last-session"}`. muxterm reuses existing PTY. Seamless. |
|
||||
| Python crashes | muxterm keeps running (independent process). Terminal stays live. Dashboard API down until Python restarts, but terminal is uninterrupted. |
|
||||
|
||||
**Key resilience property:** tmux sessions are the source of truth. Everything else (PTYs, WebSockets, muxterm, Python) is ephemeral and reconstructable. Nothing persists that can get stale or corrupt.
|
||||
|
||||
## Distribution & Lifecycle
|
||||
|
||||
### muxterm binary
|
||||
|
||||
Built as a Go binary, cross-compiled for linux/amd64, linux/arm64, darwin/amd64, darwin/arm64. Bundled into the Python package as platform-specific wheels (same pattern as ruff). `uv tool install muxplex` gets you everything — Python dashboard + Go terminal server.
|
||||
|
||||
### ghostty-web
|
||||
|
||||
Vendored into `muxplex/frontend/vendor/` alongside existing vendor pattern. WASM bundle is ~400KB. xterm.js files get removed.
|
||||
|
||||
### Process lifecycle
|
||||
|
||||
- Python FastAPI starts muxterm as a managed subprocess on startup (one process, started once)
|
||||
- muxterm listens on a localhost TCP port (e.g., 7682)
|
||||
- If muxterm dies, Python detects it and restarts it (simple process supervision)
|
||||
- On shutdown, Python sends SIGTERM to muxterm, which gracefully closes all PTYs
|
||||
- PTYs are just file descriptors — tmux sessions survive muxterm restarts (tmux is independent)
|
||||
|
||||
### What this replaces
|
||||
|
||||
- No `kill_orphan_ttyd()` — single known process, Python holds the handle
|
||||
- No port range allocation — single fixed port, not a dynamic pool
|
||||
- No PID file management — Python holds the process handle directly
|
||||
|
||||
## What Gets Deleted
|
||||
|
||||
- `ttyd.py` — all of it (process pool, PID files, port allocation, kill/spawn)
|
||||
- Python WS proxy in `main.py` (`terminal_ws_proxy`, `_ttyd_is_listening`, auto-spawn logic)
|
||||
- `POST /api/sessions/{name}/connect` endpoint — muxterm handles attach directly
|
||||
- Terminal cache in `terminal.js` (no need for multiple xterm instances)
|
||||
- ttyd as a system dependency
|
||||
- xterm.js vendor files (replaced by ghostty-web)
|
||||
|
||||
## What Gets Added
|
||||
|
||||
- `muxterm/` — Go module, small binary (~500-1000 lines)
|
||||
- ghostty-web npm package vendored into `frontend/vendor/`
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### muxterm (Go)
|
||||
|
||||
- Unit tests for PTY map operations (attach, detach, cleanup on process exit)
|
||||
- Unit tests for wire protocol parsing (control message JSON, binary frame routing)
|
||||
- Integration tests: spawn real tmux sessions, attach via WebSocket, verify I/O round-trip
|
||||
- Stress test: rapid session switching, concurrent connections
|
||||
|
||||
### Frontend
|
||||
|
||||
- Verify ghostty-web drop-in compatibility (same Terminal API as xterm.js)
|
||||
- Test session switch flow: send attach control message, verify screen repaint
|
||||
- Test error paths: attach to non-existent session, handle exited notification
|
||||
|
||||
### Integration
|
||||
|
||||
- End-to-end: browser → Python auth → muxterm → tmux, verify terminal interactivity
|
||||
- Failure scenarios: kill muxterm mid-session (verify Python restarts it, browser reconnects)
|
||||
- Verify WebSocket drop recovery (disconnect network, reconnect, verify session resumes)
|
||||
|
||||
## Migration Path
|
||||
|
||||
Migration is incremental, not a big bang. Two independent phases:
|
||||
|
||||
**Phase 1 — ghostty-web swap:** Replace xterm.js with ghostty-web in `frontend/vendor/`. Update script tags in `index.html`. API-compatible drop-in. Existing ttyd+proxy architecture stays. Immediate benefit: better VT parsing, fewer rendering bugs.
|
||||
|
||||
**Phase 2 — muxterm Go binary:** Build the Go binary, add it to the Python package, update Python startup to launch it, wire the WS endpoint. Delete `ttyd.py`, the Python WS proxy, the terminal cache, the process pool.
|
||||
|
||||
Either phase can ship and be tested independently.
|
||||
|
||||
## Design Decisions & Rationale
|
||||
|
||||
1. **Go for the terminal binary, not Rust:** Go's goroutines are purpose-built for the "copy bytes between two fds" pattern. `creack/pty` is mature. Cross-compilation is trivial. The binary is ~500-1000 lines — Go's simplicity is a feature at this scale.
|
||||
|
||||
2. **Single WebSocket with binary/text frame separation, not dual channels (Zellij pattern):** Zellij uses two separate WebSocket connections for terminal data and control. They noted the separation "ended up not being necessary." Our frame-type approach achieves the same logical separation without managing two connections.
|
||||
|
||||
3. **SIGWINCH for screen sync on session switch:** Instead of building a screen dump mechanism, leverage tmux's native redraw (triggered by SIGWINCH). This is exactly what happens when you resize a terminal — tmux repaints everything.
|
||||
|
||||
4. **Token-based auth, not socket relay:** Python issues short-lived HMAC tokens; the browser connects directly to muxterm. Eliminates any Python relay (even at the socket level). muxterm validates tokens locally with a shared secret — no callback to Python during connection. This is the cleanest "Python out of the data path" design.
|
||||
|
||||
5. **ghostty-web over xterm.js:** API-compatible drop-in with better VT100 parsing via WASM. The terminal industry is moving this direction (Coder's ghostty-web has 2.5K stars). Reduces parser bugs that are outside our control.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- ~~**Socket handoff mechanism (RESOLVED):**~~ muxterm owns its own WebSocket listener on a localhost TCP port. Python is never in the terminal data path. Auth works via short-lived HMAC tokens: Python's `/api/terminal-token` endpoint authenticates the user (cookie/bearer/localhost) and returns a 30-second HMAC token. The browser includes this token when connecting to muxterm's WebSocket. muxterm validates the HMAC locally (shared secret with Python, no callback). This eliminates any form of Python relay or socket splicing.
|
||||
- **ghostty-web maturity:** How stable is the API surface? Are there xterm.js addon equivalents (search, web links, fit) or do those need custom implementation?
|
||||
- **WASM bundle caching:** Does the ~400KB ghostty-web WASM bundle cache well across page loads, or does it need explicit Cache-Control headers?
|
||||
- **muxterm port:** Should the localhost port be configurable (env var / settings), or is a fixed default (e.g., 7682) sufficient?
|
||||
- **Federation implications:** Remote muxplex instances currently proxy through the Python WS relay. Does muxterm need its own federation-aware mode, or does Python remain the federation proxy (now forwarding to remote muxterm instances)?
|
||||
@@ -155,6 +155,7 @@ _STATIC_EXTENSIONS = {
|
||||
".woff2",
|
||||
".ttf",
|
||||
".map",
|
||||
".wasm",
|
||||
}
|
||||
|
||||
# Socket-level localhost addresses — cannot be forged via HTTP headers
|
||||
|
||||
+1
-21
@@ -436,24 +436,6 @@ def doctor() -> None:
|
||||
else:
|
||||
print(" Install: sudo apt install tmux")
|
||||
|
||||
# ttyd
|
||||
ttyd_path = shutil.which("ttyd")
|
||||
if ttyd_path:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["ttyd", "--version"], capture_output=True, text=True, timeout=5
|
||||
)
|
||||
ttyd_version = result.stdout.strip() or result.stderr.strip()
|
||||
print(f" {ok_mark} ttyd {ttyd_version}")
|
||||
except Exception:
|
||||
print(f" {ok_mark} ttyd (version unknown)")
|
||||
else:
|
||||
print(f" {fail_mark} ttyd — not found")
|
||||
if sys.platform == "darwin":
|
||||
print(" Install: brew install ttyd")
|
||||
else:
|
||||
print(" Install: sudo apt install ttyd")
|
||||
|
||||
# muxplex version + install source + update check
|
||||
try:
|
||||
from importlib.metadata import version as pkg_version # noqa: PLC0415
|
||||
@@ -636,14 +618,12 @@ def doctor() -> None:
|
||||
def _check_dependencies() -> None:
|
||||
"""Verify required external programs are installed.
|
||||
|
||||
Checks for tmux and ttyd. Prints a helpful error message and exits with
|
||||
Checks for tmux. Prints a helpful error message and exits with
|
||||
code 1 if any are missing.
|
||||
"""
|
||||
missing = []
|
||||
if shutil.which("tmux") is None:
|
||||
missing.append(("tmux", "sudo apt install tmux / brew install tmux"))
|
||||
if shutil.which("ttyd") is None:
|
||||
missing.append(("ttyd", "sudo apt install ttyd / brew install ttyd"))
|
||||
|
||||
if missing:
|
||||
print("\n ERROR: Required dependencies not found:\n", file=sys.stderr)
|
||||
|
||||
+29
-23
@@ -311,7 +311,7 @@ function trackInteraction() {
|
||||
/**
|
||||
* Restore application state from the server on page load.
|
||||
* Calls GET /api/state and, if an active session exists, re-opens it,
|
||||
* skipping only the zoom animation (ttyd is re-spawned to handle service restarts).
|
||||
* skipping only the zoom animation (muxterm handles terminal sessions).
|
||||
* Always resolves — errors are logged as warnings so the app can start normally.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
@@ -2295,6 +2295,9 @@ function _executeKill(name, remoteId) {
|
||||
.then(function() {
|
||||
closeFlyoutMenu();
|
||||
showToast('Session \'' + name + '\' killed');
|
||||
// Clean up terminal cache entry for the deleted session
|
||||
var sessionKey = remoteId ? (remoteId + ':' + name) : name;
|
||||
if (window._destroyCachedTerminal) window._destroyCachedTerminal(sessionKey);
|
||||
if (_viewingSession === name && (_viewingRemoteId ?? '') === (remoteId || '')) {
|
||||
closeSession();
|
||||
}
|
||||
@@ -2893,7 +2896,7 @@ async function openSession(name, opts = {}) {
|
||||
initSidebar();
|
||||
renderSidebar(_currentSessions, name, _viewingRemoteId);
|
||||
resolve();
|
||||
}, opts.skipAnimation ? 0 : 260);
|
||||
}, 0);
|
||||
// If setTimeout is stubbed (e.g. in test env), resolve immediately so we don't hang
|
||||
if (timerId == null) resolve();
|
||||
});
|
||||
@@ -2913,20 +2916,19 @@ async function openSession(name, opts = {}) {
|
||||
const fab = $('new-session-fab');
|
||||
if (fab) fab.classList.add('hidden');
|
||||
|
||||
// Always spawn ttyd for this session — ensures correct session after service restart or page restore
|
||||
// _deviceId holds the device_id string (was integer remoteId index in old protocol)
|
||||
var _deviceId = opts.remoteId != null ? opts.remoteId : '';
|
||||
try {
|
||||
|
||||
// Only POST /connect for remote (federation) sessions — local sessions attach via
|
||||
// muxterm's WebSocket control protocol, no backend round-trip needed.
|
||||
if (_deviceId !== '') {
|
||||
// Remote session: route connect POST through same-origin federation proxy
|
||||
try {
|
||||
await api('POST', '/api/federation/' + encodeURIComponent(_deviceId) + '/connect/' + encodeURIComponent(name));
|
||||
} else {
|
||||
await api('POST', '/api/sessions/' + encodeURIComponent(name) + '/connect');
|
||||
}
|
||||
} catch (err) {
|
||||
showToast(err.message || 'Connection failed');
|
||||
return closeSession();
|
||||
}
|
||||
}
|
||||
|
||||
// Persist active_remote_id so restoreState() can reopen remote sessions after page refresh
|
||||
api('PATCH', '/api/state', { active_session: name, active_remote_id: _deviceId || null }).catch(function() {});
|
||||
@@ -2939,8 +2941,10 @@ async function openSession(name, opts = {}) {
|
||||
// Wait for animation to finish (may already be done if /connect was slow)
|
||||
await animDone;
|
||||
|
||||
// Mount terminal NOW — /connect has completed, new ttyd is serving the correct session
|
||||
if (window._openTerminal) window._openTerminal(name, _deviceId, getDisplaySettings().fontSize);
|
||||
// Mount terminal NOW — muxterm is serving the correct session
|
||||
// Prefer _switchTerminal (cache-aware) over _openTerminal (always creates fresh)
|
||||
if (window._switchTerminal) window._switchTerminal(name, _deviceId, getDisplaySettings().fontSize);
|
||||
else if (window._openTerminal) window._openTerminal(name, _deviceId, getDisplaySettings().fontSize);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2953,10 +2957,6 @@ function closeSession() {
|
||||
|
||||
if (window._closeTerminal) window._closeTerminal();
|
||||
|
||||
// Fire-and-forget DELETE — skip for remote sessions (they don't need to know we stopped watching)
|
||||
if (_viewingRemoteId === '') {
|
||||
api('DELETE', '/api/sessions/current').catch(function() {});
|
||||
}
|
||||
// Clear active_remote_id so a page refresh does not attempt to reopen the remote session
|
||||
api('PATCH', '/api/state', { active_session: null, active_remote_id: null }).catch(function() {});
|
||||
_viewingRemoteId = '';
|
||||
@@ -3493,9 +3493,14 @@ function handleGlobalKeydown(e) {
|
||||
if (e.key === 'Escape') { closeSettings(); }
|
||||
return;
|
||||
}
|
||||
// Determine if focus is inside a text input
|
||||
// Determine if focus is inside a text input.
|
||||
// Also treat contenteditable elements (ghostty-web's terminal container uses
|
||||
// contenteditable="true") and anything inside #terminal-container as "in input"
|
||||
// so that keyboard shortcuts don't steal keystrokes from the terminal.
|
||||
const tag = document.activeElement && document.activeElement.tagName;
|
||||
const inInput = (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT');
|
||||
const inInput = (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT'
|
||||
|| !!(document.activeElement && document.activeElement.isContentEditable)
|
||||
|| !!(document.activeElement && document.activeElement.closest && document.activeElement.closest('#terminal-container')));
|
||||
// Comma key (not in inputs, no ctrl/meta) opens settings
|
||||
if (e.key === ',' && !e.ctrlKey && !e.metaKey && !inInput) {
|
||||
openSettings();
|
||||
@@ -3912,6 +3917,9 @@ function killSession(name, remoteId) {
|
||||
api('DELETE', endpoint)
|
||||
.then(function() {
|
||||
showToast('Session \'' + name + '\' killed');
|
||||
// Clean up terminal cache entry for the deleted session
|
||||
var sessionKey = remoteId ? (remoteId + ':' + name) : name;
|
||||
if (window._destroyCachedTerminal) window._destroyCachedTerminal(sessionKey);
|
||||
// If we deleted the session we're currently viewing, return to dashboard
|
||||
if (_viewingSession === name && (_viewingRemoteId ?? '') === (remoteId || '')) {
|
||||
closeSession();
|
||||
@@ -3999,16 +4007,14 @@ function bindStaticEventListeners() {
|
||||
});
|
||||
}
|
||||
|
||||
// Hover preview — component handles click internally; listen for preview-click
|
||||
// Hover preview — component dispatches preview-close when any click dismisses it.
|
||||
// pointer-events:none on the popover means the underlying element (e.g. a
|
||||
// sidebar tile) receives the click and handles navigation itself; this handler
|
||||
// only needs to clean up the timer / session-name state.
|
||||
var hoverPreview = $('hover-preview');
|
||||
if (hoverPreview) {
|
||||
hoverPreview.addEventListener('preview-click', function(e) {
|
||||
hoverPreview.addEventListener('preview-close', function() {
|
||||
hidePreview();
|
||||
var name = e.detail.name;
|
||||
if (name) {
|
||||
var session = _currentSessions && _currentSessions.find(function(s) { return s.name === name; });
|
||||
openSession(name, { remoteId: (session != null && session.remoteId != null) ? session.remoteId : '' });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,8 @@ export class HoverPreview extends LitElement {
|
||||
border-radius: 8px;
|
||||
overflow: auto;
|
||||
padding: 12px;
|
||||
cursor: pointer;
|
||||
cursor: default;
|
||||
pointer-events: none; /* clicks pass through to sidebar tiles below */
|
||||
}
|
||||
.preview-popover pre {
|
||||
margin: 0;
|
||||
@@ -74,11 +75,13 @@ export class HoverPreview extends LitElement {
|
||||
}
|
||||
|
||||
_onDocumentClick(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.dispatchEvent(new CustomEvent('preview-click', {
|
||||
// Close the preview. Do NOT call stopPropagation() — that would swallow
|
||||
// clicks intended for sidebar tiles and other elements behind the preview.
|
||||
// pointer-events:none on .preview-popover lets clicks reach their real
|
||||
// target; this handler just ensures the overlay is dismissed in sync.
|
||||
this.open = false;
|
||||
this.dispatchEvent(new CustomEvent('preview-close', {
|
||||
bubbles: true, composed: true,
|
||||
detail: { name: this.sessionName },
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<link rel="stylesheet" href="/vendor/xterm.css" />
|
||||
<!-- ghostty-web uses canvas rendering, no CSS needed -->
|
||||
<link rel="stylesheet" href="/style.css" />
|
||||
<title>muxplex</title>
|
||||
</head>
|
||||
@@ -269,8 +269,7 @@
|
||||
</dialog>
|
||||
|
||||
<!-- ── Scripts ──────────────────────────────────────────────────────────── -->
|
||||
<script src="/vendor/xterm.js"></script>
|
||||
<script src="/vendor/xterm-addon-fit.js"></script>
|
||||
<script src="/vendor/ghostty-web.js"></script>
|
||||
<script src="/vendor/xterm-addon-web-links.js"></script>
|
||||
<script src="/vendor/xterm-addon-search.js"></script>
|
||||
<script src="/vendor/addon-image.js"></script>
|
||||
|
||||
@@ -501,7 +501,6 @@ body {
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
transition: width 0.25s ease, min-width 0.25s ease;
|
||||
}
|
||||
|
||||
.session-sidebar.sidebar--collapsed {
|
||||
@@ -735,7 +734,6 @@ body {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
padding: 0 4px; /* keep text off the side edges */
|
||||
}
|
||||
|
||||
/* Terminal search bar */
|
||||
@@ -818,12 +816,6 @@ body {
|
||||
position: fixed;
|
||||
z-index: 50;
|
||||
border-radius: 4px;
|
||||
transition:
|
||||
top var(--t-zoom),
|
||||
left var(--t-zoom),
|
||||
width var(--t-zoom),
|
||||
height var(--t-zoom),
|
||||
border-radius var(--t-zoom);
|
||||
}
|
||||
|
||||
.session-tile--expanded {
|
||||
|
||||
+310
-406
File diff suppressed because it is too large
Load Diff
@@ -1126,14 +1126,14 @@ test('openSession with skipAnimation calls window._openTerminal after connect PO
|
||||
globalThis.setTimeout = origSetTimeout;
|
||||
});
|
||||
|
||||
test('openSession without skipConnect POSTs to /api/sessions/{name}/connect', async () => {
|
||||
test('openSession for local session does NOT POST to /connect (muxterm handles attach via WebSocket)', async () => {
|
||||
const fetchCalls = [];
|
||||
const origFetch = globalThis.fetch;
|
||||
const origGetById = globalThis.document.getElementById;
|
||||
const origQS = globalThis.document.querySelector;
|
||||
const origSetTimeout = globalThis.setTimeout;
|
||||
globalThis.fetch = async (url, opts) => { fetchCalls.push({ url, opts }); return { ok: true }; };
|
||||
globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { remove: () => {}, add: () => {} } });
|
||||
globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { remove: () => {}, add: () => {} }, querySelector: () => null });
|
||||
globalThis.document.querySelector = () => null;
|
||||
globalThis.setTimeout = () => {};
|
||||
globalThis.window._openTerminal = () => {};
|
||||
@@ -1141,17 +1141,16 @@ test('openSession without skipConnect POSTs to /api/sessions/{name}/connect', as
|
||||
await app.openSession('work', {});
|
||||
|
||||
const connectCall = fetchCalls.find((c) => c.url === '/api/sessions/work/connect');
|
||||
assert.ok(connectCall, 'should POST to /api/sessions/work/connect');
|
||||
assert.strictEqual(connectCall.opts.method, 'POST');
|
||||
assert.ok(!connectCall, 'local session should NOT POST to /api/sessions/{name}/connect');
|
||||
globalThis.fetch = origFetch;
|
||||
globalThis.document.getElementById = origGetById;
|
||||
globalThis.document.querySelector = origQS;
|
||||
globalThis.setTimeout = origSetTimeout;
|
||||
});
|
||||
|
||||
test('openSession shows toast and calls closeSession on connect failure', async () => {
|
||||
test('openSession shows toast and calls closeSession on remote connect failure', async () => {
|
||||
let closeTerminalCalled = false;
|
||||
const mockToast = { textContent: '', classList: { remove: () => {}, add: () => {} } };
|
||||
let toastMsg = '';
|
||||
const origFetch = globalThis.fetch;
|
||||
const origGetById = globalThis.document.getElementById;
|
||||
const origQS = globalThis.document.querySelector;
|
||||
@@ -1161,17 +1160,18 @@ test('openSession shows toast and calls closeSession on connect failure', async
|
||||
return { ok: true };
|
||||
};
|
||||
globalThis.document.getElementById = (id) => {
|
||||
if (id === 'toast') return mockToast;
|
||||
return { textContent: '', style: {}, classList: { remove: () => {}, add: () => {} } };
|
||||
if (id === 'toast') return { show: (msg) => { toastMsg = msg; }, textContent: '', classList: { remove: () => {}, add: () => {} }, querySelector: () => null };
|
||||
return { textContent: '', style: {}, classList: { remove: () => {}, add: () => {} }, querySelector: () => null };
|
||||
};
|
||||
globalThis.document.querySelector = () => null;
|
||||
globalThis.setTimeout = () => {};
|
||||
globalThis.window._openTerminal = () => {};
|
||||
globalThis.window._closeTerminal = () => { closeTerminalCalled = true; };
|
||||
|
||||
await app.openSession('failing-session', {});
|
||||
// Use a remote session so /connect is actually called (local sessions skip /connect)
|
||||
await app.openSession('failing-session', { remoteId: 'fed-fail' });
|
||||
|
||||
assert.ok(mockToast.textContent.length > 0, 'toast should show an error message');
|
||||
assert.ok(toastMsg.length > 0, 'toast should show an error message');
|
||||
assert.ok(closeTerminalCalled, 'closeSession should be called (_closeTerminal invoked)');
|
||||
globalThis.fetch = origFetch;
|
||||
globalThis.document.getElementById = origGetById;
|
||||
@@ -1186,7 +1186,7 @@ test('openSession with remoteId POSTs connect to federation proxy URL', async ()
|
||||
const origQS = globalThis.document.querySelector;
|
||||
const origSetTimeout = globalThis.setTimeout;
|
||||
globalThis.fetch = async (url, opts) => { fetchCalls.push({ url, opts }); return { ok: true }; };
|
||||
globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { remove: () => {}, add: () => {} } });
|
||||
globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { remove: () => {}, add: () => {} }, querySelector: () => null });
|
||||
globalThis.document.querySelector = () => null;
|
||||
globalThis.setTimeout = () => {};
|
||||
globalThis.window._openTerminal = () => {};
|
||||
@@ -1209,7 +1209,7 @@ test('openSession with remoteId passes remoteId to window._openTerminal', async
|
||||
const origQS = globalThis.document.querySelector;
|
||||
const origSetTimeout = globalThis.setTimeout;
|
||||
globalThis.fetch = async () => ({ ok: true });
|
||||
globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { remove: () => {}, add: () => {} } });
|
||||
globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { remove: () => {}, add: () => {} }, querySelector: () => null });
|
||||
globalThis.document.querySelector = () => null;
|
||||
globalThis.setTimeout = (fn) => { fn(); };
|
||||
globalThis.window._openTerminal = (...args) => { openTerminalArgs = args; };
|
||||
@@ -1251,14 +1251,14 @@ test('openSession passes getDisplaySettings().fontSize to window._openTerminal a
|
||||
globalThis.setTimeout = origSetTimeout;
|
||||
});
|
||||
|
||||
test('openSession for local session still POSTs to local /api/sessions/{name}/connect', async () => {
|
||||
test('openSession for local session skips /connect POST (muxterm handles attach via WebSocket)', async () => {
|
||||
const fetchCalls = [];
|
||||
const origFetch = globalThis.fetch;
|
||||
const origGetById = globalThis.document.getElementById;
|
||||
const origQS = globalThis.document.querySelector;
|
||||
const origSetTimeout = globalThis.setTimeout;
|
||||
globalThis.fetch = async (url, opts) => { fetchCalls.push({ url, opts }); return { ok: true }; };
|
||||
globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { remove: () => {}, add: () => {} } });
|
||||
globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { remove: () => {}, add: () => {} }, querySelector: () => null });
|
||||
globalThis.document.querySelector = () => null;
|
||||
globalThis.setTimeout = () => {};
|
||||
globalThis.window._openTerminal = () => {};
|
||||
@@ -1266,8 +1266,7 @@ test('openSession for local session still POSTs to local /api/sessions/{name}/co
|
||||
await app.openSession('local-session', {});
|
||||
|
||||
const connectCall = fetchCalls.find((c) => c.url === '/api/sessions/local-session/connect');
|
||||
assert.ok(connectCall, 'should POST to /api/sessions/local-session/connect');
|
||||
assert.strictEqual(connectCall.opts.method, 'POST');
|
||||
assert.ok(!connectCall, 'local session should NOT POST to /api/sessions/{name}/connect');
|
||||
globalThis.fetch = origFetch;
|
||||
globalThis.document.getElementById = origGetById;
|
||||
globalThis.document.querySelector = origQS;
|
||||
@@ -1354,7 +1353,7 @@ test('closeSession calls window._closeTerminal', async () => {
|
||||
globalThis.fetch = undefined;
|
||||
});
|
||||
|
||||
test('closeSession fires DELETE /api/sessions/current', async () => {
|
||||
test('closeSession does NOT fire DELETE /api/sessions/current (detach handled by closeTerminal)', async () => {
|
||||
const fetchCalls = [];
|
||||
const origFetch = globalThis.fetch;
|
||||
const origGetById = globalThis.document.getElementById;
|
||||
@@ -1363,16 +1362,16 @@ test('closeSession fires DELETE /api/sessions/current', async () => {
|
||||
globalThis.window._closeTerminal = () => {};
|
||||
|
||||
await app.closeSession();
|
||||
// yield microtask queue for fire-and-forget DELETE
|
||||
// yield microtask queue for any fire-and-forget calls
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
const deleteCall = fetchCalls.find((c) => c.url === '/api/sessions/current' && c.opts && c.opts.method === 'DELETE');
|
||||
assert.ok(deleteCall, 'should fire DELETE /api/sessions/current');
|
||||
assert.ok(!deleteCall, 'closeSession should NOT fire DELETE /api/sessions/current');
|
||||
globalThis.fetch = origFetch;
|
||||
globalThis.document.getElementById = origGetById;
|
||||
});
|
||||
|
||||
test('closeSession does NOT fire DELETE for remote session (non-empty _viewingRemoteId)', async () => {
|
||||
test('closeSession does NOT fire DELETE for remote session (detach handled by closeTerminal)', async () => {
|
||||
const origFetch = globalThis.fetch;
|
||||
const origGetById = globalThis.document.getElementById;
|
||||
const origQS = globalThis.document.querySelector;
|
||||
@@ -1380,7 +1379,7 @@ test('closeSession does NOT fire DELETE for remote session (non-empty _viewingRe
|
||||
|
||||
// Setup to call openSession with remote remoteId
|
||||
globalThis.fetch = async () => ({ ok: true });
|
||||
globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { remove: () => {}, add: () => {} } });
|
||||
globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { remove: () => {}, add: () => {} }, querySelector: () => null });
|
||||
globalThis.document.querySelector = () => null;
|
||||
globalThis.setTimeout = () => {};
|
||||
globalThis.window._openTerminal = () => {};
|
||||
@@ -1410,7 +1409,7 @@ test('closeSession does NOT fire DELETE for remote session (non-empty _viewingRe
|
||||
globalThis.setTimeout = origSetTimeout;
|
||||
});
|
||||
|
||||
test('closeSession still fires DELETE /api/sessions/current for local session', async () => {
|
||||
test('closeSession does NOT fire DELETE /api/sessions/current for local session (detach handled by closeTerminal)', async () => {
|
||||
const origFetch = globalThis.fetch;
|
||||
const origGetById = globalThis.document.getElementById;
|
||||
const origQS = globalThis.document.querySelector;
|
||||
@@ -1418,7 +1417,7 @@ test('closeSession still fires DELETE /api/sessions/current for local session',
|
||||
|
||||
// Setup to call openSession for a local session (no remoteId)
|
||||
globalThis.fetch = async () => ({ ok: true });
|
||||
globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { remove: () => {}, add: () => {} } });
|
||||
globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { remove: () => {}, add: () => {} }, querySelector: () => null });
|
||||
globalThis.document.querySelector = () => null;
|
||||
globalThis.setTimeout = () => {};
|
||||
globalThis.window._openTerminal = () => {};
|
||||
@@ -1436,11 +1435,11 @@ test('closeSession still fires DELETE /api/sessions/current for local session',
|
||||
|
||||
// Close session
|
||||
await app.closeSession();
|
||||
// yield microtask queue for fire-and-forget DELETE
|
||||
// yield microtask queue for any fire-and-forget calls
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
const deleteCall = fetchCalls.find((c) => c.url === '/api/sessions/current' && c.opts && c.opts.method === 'DELETE');
|
||||
assert.ok(deleteCall, 'closeSession should fire DELETE /api/sessions/current for local session');
|
||||
assert.ok(!deleteCall, 'closeSession should NOT fire DELETE /api/sessions/current — detach is handled by closeTerminal()');
|
||||
|
||||
globalThis.fetch = origFetch;
|
||||
globalThis.document.getElementById = origGetById;
|
||||
@@ -2075,14 +2074,14 @@ test('bindSidebarClickAway registers click listener on terminal-container', () =
|
||||
globalThis.document.getElementById = origGetById;
|
||||
});
|
||||
|
||||
test('openSession mounts terminal AFTER connect POST, not inside animation timer', () => {
|
||||
test('openSession mounts terminal AFTER federation connect POST, not inside animation timer', () => {
|
||||
const source = fs.readFileSync(
|
||||
new URL('../app.js', import.meta.url), 'utf8'
|
||||
);
|
||||
|
||||
// Find the openSession function body
|
||||
const fnStart = source.indexOf('async function openSession');
|
||||
const fnBody = source.substring(fnStart, fnStart + 4000);
|
||||
const fnBody = source.substring(fnStart, fnStart + 5000);
|
||||
|
||||
// _openTerminal must NOT appear inside setTimeout
|
||||
const setTimeoutIdx = fnBody.indexOf('setTimeout');
|
||||
@@ -2092,11 +2091,11 @@ test('openSession mounts terminal AFTER connect POST, not inside animation timer
|
||||
assert.ok(!setTimeoutBody.includes('_openTerminal'),
|
||||
'_openTerminal must NOT be inside the 260ms setTimeout — causes race condition with /connect POST');
|
||||
|
||||
// _openTerminal must appear AFTER the /connect POST
|
||||
const connectIdx = fnBody.indexOf('/api/sessions/');
|
||||
// _openTerminal must appear AFTER the federation connect POST
|
||||
const connectIdx = fnBody.indexOf('/api/federation/');
|
||||
const openTermIdx = fnBody.indexOf('_openTerminal', connectIdx);
|
||||
assert.ok(openTermIdx > connectIdx,
|
||||
'_openTerminal must appear AFTER the /connect POST in the source');
|
||||
'_openTerminal must appear AFTER the federation connect POST in the source');
|
||||
});
|
||||
|
||||
// --- Hover preview popover ---
|
||||
@@ -2990,16 +2989,15 @@ test('CSS style.css has tile--loading and shimmer animation', () => {
|
||||
|
||||
// --- Issue 2: Always call connect on restore ---
|
||||
|
||||
test('openSession always POSTs to connect even when skipConnect option is passed', async () => {
|
||||
// Before the fix, skipConnect:true skipped the connect POST entirely.
|
||||
// After the fix, connect is always called; the option is renamed to skipAnimation.
|
||||
test('openSession for local session does NOT POST to connect regardless of skipConnect option', async () => {
|
||||
// Local sessions no longer POST to /connect — muxterm handles attach via WebSocket.
|
||||
const fetchCalls = [];
|
||||
const origFetch = globalThis.fetch;
|
||||
const origGetById = globalThis.document.getElementById;
|
||||
const origQS = globalThis.document.querySelector;
|
||||
const origSetTimeout = globalThis.setTimeout;
|
||||
globalThis.fetch = async (url, opts) => { fetchCalls.push({ url, opts }); return { ok: true }; };
|
||||
globalThis.document.getElementById = () => ({ textContent: '', style: { removeProperty: () => {} }, classList: { remove: () => {}, add: () => {} } });
|
||||
globalThis.document.getElementById = () => ({ textContent: '', style: { removeProperty: () => {} }, classList: { remove: () => {}, add: () => {} }, querySelector: () => null });
|
||||
globalThis.document.querySelector = () => null;
|
||||
globalThis.setTimeout = () => {};
|
||||
globalThis.window._openTerminal = () => {};
|
||||
@@ -3007,8 +3005,7 @@ test('openSession always POSTs to connect even when skipConnect option is passed
|
||||
await app.openSession('work', { skipConnect: true });
|
||||
|
||||
const connectCall = fetchCalls.find((c) => c.url === '/api/sessions/work/connect');
|
||||
assert.ok(connectCall, 'skipConnect:true must NOT prevent connect POST — connect always fires after fix');
|
||||
assert.strictEqual(connectCall.opts.method, 'POST');
|
||||
assert.ok(!connectCall, 'local session should NOT POST to /connect — muxterm handles attach via WebSocket');
|
||||
globalThis.fetch = origFetch;
|
||||
globalThis.document.getElementById = origGetById;
|
||||
globalThis.document.querySelector = origQS;
|
||||
@@ -4113,11 +4110,11 @@ test('tile click handler ignores clicks on tile-options-btn button', () => {
|
||||
|
||||
// --- index.html: self-hosted vendor libs (no CDN) ---
|
||||
|
||||
test('index.html loads xterm.css from local /vendor/ path (not CDN)', () => {
|
||||
test('index.html loads ghostty-web.js from local /vendor/ path (not CDN)', () => {
|
||||
const html = fs.readFileSync(new URL('../index.html', import.meta.url), 'utf8');
|
||||
assert.ok(
|
||||
html.includes('href="/vendor/xterm.css"'),
|
||||
'index.html must reference /vendor/xterm.css (not CDN)',
|
||||
html.includes('ghostty-web.js'),
|
||||
'index.html must reference ghostty-web.js (not xterm.js)',
|
||||
);
|
||||
assert.ok(
|
||||
!html.includes('cdn.jsdelivr.net'),
|
||||
@@ -4125,11 +4122,11 @@ test('index.html loads xterm.css from local /vendor/ path (not CDN)', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('index.html loads all 5 xterm JS scripts from local /vendor/ paths (not CDN)', () => {
|
||||
test('index.html loads ghostty-web and remaining addon scripts from local /vendor/ paths (not CDN)', () => {
|
||||
const html = fs.readFileSync(new URL('../index.html', import.meta.url), 'utf8');
|
||||
// ghostty-web replaces xterm.js + xterm-addon-fit.js (fit is built-in)
|
||||
const vendorScripts = [
|
||||
'/vendor/xterm.js',
|
||||
'/vendor/xterm-addon-fit.js',
|
||||
'/vendor/ghostty-web.js',
|
||||
'/vendor/xterm-addon-web-links.js',
|
||||
'/vendor/xterm-addon-search.js',
|
||||
'/vendor/addon-image.js',
|
||||
@@ -4180,7 +4177,7 @@ test('openSession with integer remoteId=0 POSTs to federation proxy URL, not loc
|
||||
const origQS = globalThis.document.querySelector;
|
||||
const origSetTimeout = globalThis.setTimeout;
|
||||
globalThis.fetch = async (url, opts) => { fetchCalls.push({ url, opts }); return { ok: true }; };
|
||||
globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { remove: () => {}, add: () => {} } });
|
||||
globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { remove: () => {}, add: () => {} }, querySelector: () => null });
|
||||
globalThis.document.querySelector = () => null;
|
||||
globalThis.setTimeout = () => {};
|
||||
globalThis.window._openTerminal = () => {};
|
||||
@@ -4208,7 +4205,7 @@ test('openSession with integer remoteId=0 passes 0 to window._openTerminal as se
|
||||
const origQS = globalThis.document.querySelector;
|
||||
const origSetTimeout = globalThis.setTimeout;
|
||||
globalThis.fetch = async () => ({ ok: true });
|
||||
globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { remove: () => {}, add: () => {} } });
|
||||
globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { remove: () => {}, add: () => {} }, querySelector: () => null });
|
||||
globalThis.document.querySelector = () => null;
|
||||
globalThis.setTimeout = (fn) => { fn(); };
|
||||
globalThis.window._openTerminal = (...args) => { openTerminalArgs = args; };
|
||||
@@ -4233,7 +4230,7 @@ test('closeSession after openSession with remoteId=0 does NOT fire DELETE /api/s
|
||||
|
||||
// Open a remote session with remoteId=0 — sets _viewingRemoteId = 0
|
||||
globalThis.fetch = async () => ({ ok: true });
|
||||
globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { remove: () => {}, add: () => {} } });
|
||||
globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { remove: () => {}, add: () => {} }, querySelector: () => null });
|
||||
globalThis.document.querySelector = () => null;
|
||||
globalThis.setTimeout = () => {};
|
||||
globalThis.window._openTerminal = () => {};
|
||||
@@ -4568,7 +4565,7 @@ test('openSession PATCHes /api/state with active_remote_id after successful conn
|
||||
const origQS = globalThis.document.querySelector;
|
||||
const origSetTimeout = globalThis.setTimeout;
|
||||
globalThis.fetch = async (url, opts) => { fetchCalls.push({ url, opts }); return { ok: true }; };
|
||||
globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { remove: () => {}, add: () => {} } });
|
||||
globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { remove: () => {}, add: () => {} }, querySelector: () => null });
|
||||
globalThis.document.querySelector = () => null;
|
||||
globalThis.setTimeout = (fn) => { fn(); }; // invoke immediately so animation resolves
|
||||
globalThis.window._openTerminal = () => {};
|
||||
@@ -4594,7 +4591,7 @@ test('closeSession PATCHes /api/state to clear active_remote_id', async () => {
|
||||
|
||||
// Open a remote session first so _viewingRemoteId is set
|
||||
globalThis.fetch = async () => ({ ok: true });
|
||||
globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { remove: () => {}, add: () => {} } });
|
||||
globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { remove: () => {}, add: () => {} }, querySelector: () => null });
|
||||
globalThis.document.querySelector = () => null;
|
||||
globalThis.setTimeout = (fn) => { fn(); };
|
||||
globalThis.window._openTerminal = () => {};
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
// localStorage stub — must be set before importing app.js
|
||||
let _localStorageStore = {};
|
||||
globalThis.localStorage = {
|
||||
getItem: (key) => (Object.prototype.hasOwnProperty.call(_localStorageStore, key) ? _localStorageStore[key] : null),
|
||||
setItem: (key, value) => { _localStorageStore[key] = String(value); },
|
||||
removeItem: (key) => { delete _localStorageStore[key]; },
|
||||
};
|
||||
|
||||
// Browser global stubs — must be set before importing app.js
|
||||
globalThis.document = {
|
||||
getElementById: () => null,
|
||||
querySelector: () => null,
|
||||
querySelectorAll: () => [],
|
||||
createElement: () => ({ style: {}, classList: { add: () => {}, remove: () => {} } }),
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
};
|
||||
|
||||
// Stubs for functions called by pollSessions (implemented in later tasks)
|
||||
globalThis.renderGrid = () => {};
|
||||
globalThis.handleBellTransitions = () => {};
|
||||
globalThis.openSession = () => {};
|
||||
globalThis.updatePillBell = () => {};
|
||||
|
||||
globalThis.window = {
|
||||
addEventListener: () => {},
|
||||
location: { href: '' },
|
||||
innerWidth: 1024,
|
||||
};
|
||||
|
||||
globalThis.Notification = {
|
||||
permission: 'default',
|
||||
requestPermission: async () => 'default',
|
||||
};
|
||||
|
||||
// navigator is read-only in Node v24+, use defineProperty
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
value: { userAgent: 'test-agent' },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
import { createRequire } from 'node:module';
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const app = require(join(__dirname, '..', 'app.js'));
|
||||
|
||||
// Helper: returns a mock DOM element with querySelector support
|
||||
function mockEl() {
|
||||
return { textContent: '', style: {}, classList: { remove: () => {}, add: () => {} }, querySelector: () => null };
|
||||
}
|
||||
|
||||
// --- Task 12: openSession should NOT POST /connect for local sessions ---
|
||||
|
||||
test('openSession for local session does NOT POST to /connect', async () => {
|
||||
const fetchCalls = [];
|
||||
const origFetch = globalThis.fetch;
|
||||
const origGetById = globalThis.document.getElementById;
|
||||
const origQS = globalThis.document.querySelector;
|
||||
const origSetTimeout = globalThis.setTimeout;
|
||||
globalThis.fetch = async (url, opts) => { fetchCalls.push({ url, opts }); return { ok: true }; };
|
||||
globalThis.document.getElementById = () => mockEl();
|
||||
globalThis.document.querySelector = () => null;
|
||||
globalThis.setTimeout = () => {};
|
||||
globalThis.window._openTerminal = () => {};
|
||||
|
||||
await app.openSession('local-session', {});
|
||||
|
||||
// Local sessions should NOT POST to /api/sessions/{name}/connect
|
||||
const connectCall = fetchCalls.find((c) => c.url === '/api/sessions/local-session/connect');
|
||||
assert.ok(!connectCall, 'local session should NOT POST to /api/sessions/{name}/connect');
|
||||
globalThis.fetch = origFetch;
|
||||
globalThis.document.getElementById = origGetById;
|
||||
globalThis.document.querySelector = origQS;
|
||||
globalThis.setTimeout = origSetTimeout;
|
||||
});
|
||||
|
||||
test('openSession for remote session still POSTs to federation connect', async () => {
|
||||
const fetchCalls = [];
|
||||
const origFetch = globalThis.fetch;
|
||||
const origGetById = globalThis.document.getElementById;
|
||||
const origQS = globalThis.document.querySelector;
|
||||
const origSetTimeout = globalThis.setTimeout;
|
||||
globalThis.fetch = async (url, opts) => { fetchCalls.push({ url, opts }); return { ok: true }; };
|
||||
globalThis.document.getElementById = () => mockEl();
|
||||
globalThis.document.querySelector = () => null;
|
||||
globalThis.setTimeout = () => {};
|
||||
globalThis.window._openTerminal = () => {};
|
||||
|
||||
await app.openSession('work-project', { remoteId: 'fed-abc123' });
|
||||
|
||||
const connectCall = fetchCalls.find((c) => c.url === '/api/federation/fed-abc123/connect/work-project');
|
||||
assert.ok(connectCall, 'remote session should POST to federation connect');
|
||||
assert.strictEqual(connectCall.opts.method, 'POST');
|
||||
globalThis.fetch = origFetch;
|
||||
globalThis.document.getElementById = origGetById;
|
||||
globalThis.document.querySelector = origQS;
|
||||
globalThis.setTimeout = origSetTimeout;
|
||||
});
|
||||
|
||||
test('openSession for remote session shows toast and closes on federation connect failure', async () => {
|
||||
let closeTerminalCalled = false;
|
||||
let toastMsg = '';
|
||||
const mockToast = { show: (msg) => { toastMsg = msg; }, textContent: '', classList: { remove: () => {}, add: () => {} }, querySelector: () => null };
|
||||
const origFetch = globalThis.fetch;
|
||||
const origGetById = globalThis.document.getElementById;
|
||||
const origQS = globalThis.document.querySelector;
|
||||
const origSetTimeout = globalThis.setTimeout;
|
||||
globalThis.fetch = async (url) => {
|
||||
if (url.includes('/connect')) throw new Error('Connection failed');
|
||||
return { ok: true };
|
||||
};
|
||||
globalThis.document.getElementById = (id) => {
|
||||
if (id === 'toast') return mockToast;
|
||||
return mockEl();
|
||||
};
|
||||
globalThis.document.querySelector = () => null;
|
||||
globalThis.setTimeout = () => {};
|
||||
globalThis.window._openTerminal = () => {};
|
||||
globalThis.window._closeTerminal = () => { closeTerminalCalled = true; };
|
||||
|
||||
// Use a remote session so /connect is actually called
|
||||
await app.openSession('failing-session', { remoteId: 'fed-xyz' });
|
||||
|
||||
assert.ok(toastMsg.length > 0, 'toast should show an error message');
|
||||
assert.ok(closeTerminalCalled, 'closeSession should be called (_closeTerminal invoked)');
|
||||
globalThis.fetch = origFetch;
|
||||
globalThis.document.getElementById = origGetById;
|
||||
globalThis.document.querySelector = origQS;
|
||||
globalThis.setTimeout = origSetTimeout;
|
||||
});
|
||||
|
||||
// --- Task 12: closeSession should NOT fire DELETE /api/sessions/current ---
|
||||
|
||||
test('closeSession does NOT fire DELETE /api/sessions/current for local session', async () => {
|
||||
const origFetch = globalThis.fetch;
|
||||
const origGetById = globalThis.document.getElementById;
|
||||
const origQS = globalThis.document.querySelector;
|
||||
const origSetTimeout = globalThis.setTimeout;
|
||||
|
||||
// Setup to call openSession for a local session (no remoteId)
|
||||
globalThis.fetch = async () => ({ ok: true });
|
||||
globalThis.document.getElementById = () => mockEl();
|
||||
globalThis.document.querySelector = () => null;
|
||||
globalThis.setTimeout = () => {};
|
||||
globalThis.window._openTerminal = () => {};
|
||||
globalThis.window._closeTerminal = () => {};
|
||||
|
||||
// Open a local session - this sets _viewingRemoteId = ''
|
||||
await app.openSession('local-sess', {});
|
||||
|
||||
// Restore setTimeout so Promise-based yielding works
|
||||
globalThis.setTimeout = origSetTimeout;
|
||||
|
||||
// Reset fetch tracking
|
||||
const fetchCalls = [];
|
||||
globalThis.fetch = async (url, opts) => { fetchCalls.push({ url, opts }); return { ok: true }; };
|
||||
|
||||
// Close session
|
||||
await app.closeSession();
|
||||
// yield microtask queue for any fire-and-forget calls
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
const deleteCall = fetchCalls.find((c) => c.url === '/api/sessions/current' && c.opts && c.opts.method === 'DELETE');
|
||||
assert.ok(!deleteCall, 'closeSession should NOT fire DELETE /api/sessions/current — detach is handled by closeTerminal()');
|
||||
|
||||
globalThis.fetch = origFetch;
|
||||
globalThis.document.getElementById = origGetById;
|
||||
globalThis.document.querySelector = origQS;
|
||||
globalThis.setTimeout = origSetTimeout;
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
BIN
Binary file not shown.
+13
File diff suppressed because one or more lines are too long
-2
@@ -1,2 +0,0 @@
|
||||
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(self,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core,t=e._renderService.dimensions;if(0===t.css.cell.width||0===t.css.cell.height)return;const r=0===this._terminal.options.scrollback?0:e.viewport.scrollBarWidth,i=window.getComputedStyle(this._terminal.element.parentElement),o=parseInt(i.getPropertyValue("height")),s=Math.max(0,parseInt(i.getPropertyValue("width"))),n=window.getComputedStyle(this._terminal.element),l=o-(parseInt(n.getPropertyValue("padding-top"))+parseInt(n.getPropertyValue("padding-bottom"))),a=s-(parseInt(n.getPropertyValue("padding-right"))+parseInt(n.getPropertyValue("padding-left")))-r;return{cols:Math.max(2,Math.floor(a/t.css.cell.width)),rows:Math.max(1,Math.floor(l/t.css.cell.height))}}}})(),e})()));
|
||||
//# sourceMappingURL=xterm-addon-fit.js.map
|
||||
Vendored
-209
@@ -1,209 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
|
||||
* Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
|
||||
* https://github.com/chjj/term.js
|
||||
* @license MIT
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in
|
||||
* all copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
* THE SOFTWARE.
|
||||
*
|
||||
* Originally forked from (with the author's permission):
|
||||
* Fabrice Bellard's javascript vt100 for jslinux:
|
||||
* http://bellard.org/jslinux/
|
||||
* Copyright (c) 2011 Fabrice Bellard
|
||||
* The original design remains. The terminal itself
|
||||
* has been extended to include xterm CSI codes, among
|
||||
* other features.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Default styles for xterm.js
|
||||
*/
|
||||
|
||||
.xterm {
|
||||
cursor: text;
|
||||
position: relative;
|
||||
user-select: none;
|
||||
-ms-user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.xterm.focus,
|
||||
.xterm:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.xterm .xterm-helpers {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
/**
|
||||
* The z-index of the helpers must be higher than the canvases in order for
|
||||
* IMEs to appear on top.
|
||||
*/
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.xterm .xterm-helper-textarea {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
margin: 0;
|
||||
/* Move textarea out of the screen to the far left, so that the cursor is not visible */
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
left: -9999em;
|
||||
top: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
z-index: -5;
|
||||
/** Prevent wrapping so the IME appears against the textarea at the correct position */
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.xterm .composition-view {
|
||||
/* TODO: Composition position got messed up somewhere */
|
||||
background: #000;
|
||||
color: #FFF;
|
||||
display: none;
|
||||
position: absolute;
|
||||
white-space: nowrap;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.xterm .composition-view.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.xterm .xterm-viewport {
|
||||
/* On OS X this is required in order for the scroll bar to appear fully opaque */
|
||||
background-color: #000;
|
||||
overflow-y: scroll;
|
||||
cursor: default;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.xterm .xterm-screen {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.xterm .xterm-screen canvas {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.xterm .xterm-scroll-area {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.xterm-char-measure-element {
|
||||
display: inline-block;
|
||||
visibility: hidden;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -9999em;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
.xterm.enable-mouse-events {
|
||||
/* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.xterm.xterm-cursor-pointer,
|
||||
.xterm .xterm-cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.xterm.column-select.focus {
|
||||
/* Column selection mode */
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.xterm .xterm-accessibility,
|
||||
.xterm .xterm-message {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
color: transparent;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.xterm .live-region {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.xterm-dim {
|
||||
/* Dim should not apply to background, so the opacity of the foreground color is applied
|
||||
* explicitly in the generated class and reset to 1 here */
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.xterm-underline-1 { text-decoration: underline; }
|
||||
.xterm-underline-2 { text-decoration: double underline; }
|
||||
.xterm-underline-3 { text-decoration: wavy underline; }
|
||||
.xterm-underline-4 { text-decoration: dotted underline; }
|
||||
.xterm-underline-5 { text-decoration: dashed underline; }
|
||||
|
||||
.xterm-overline {
|
||||
text-decoration: overline;
|
||||
}
|
||||
|
||||
.xterm-overline.xterm-underline-1 { text-decoration: overline underline; }
|
||||
.xterm-overline.xterm-underline-2 { text-decoration: overline double underline; }
|
||||
.xterm-overline.xterm-underline-3 { text-decoration: overline wavy underline; }
|
||||
.xterm-overline.xterm-underline-4 { text-decoration: overline dotted underline; }
|
||||
.xterm-overline.xterm-underline-5 { text-decoration: overline dashed underline; }
|
||||
|
||||
.xterm-strikethrough {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.xterm-screen .xterm-decoration-container .xterm-decoration {
|
||||
z-index: 6;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer {
|
||||
z-index: 7;
|
||||
}
|
||||
|
||||
.xterm-decoration-overview-ruler {
|
||||
z-index: 8;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.xterm-decoration-top {
|
||||
z-index: 2;
|
||||
position: relative;
|
||||
}
|
||||
Vendored
-2
File diff suppressed because one or more lines are too long
+156
-172
@@ -10,6 +10,7 @@ Background poll loop reconciles tmux session state every POLL_INTERVAL seconds.
|
||||
import asyncio
|
||||
import contextlib
|
||||
import copy
|
||||
import hashlib
|
||||
import hmac
|
||||
import importlib.metadata
|
||||
import json
|
||||
@@ -18,6 +19,7 @@ import os
|
||||
import pathlib
|
||||
import pwd
|
||||
import re
|
||||
import secrets as _secrets_mod
|
||||
import socket
|
||||
import ssl
|
||||
import shlex
|
||||
@@ -78,7 +80,7 @@ from muxplex.settings import (
|
||||
from muxplex.pruning import load_pruning_state, save_pruning_state
|
||||
from muxplex.views import normalize_session_keys, prune_stale_keys
|
||||
from muxplex.identity import load_device_id
|
||||
from muxplex.ttyd import kill_orphan_ttyd, kill_ttyd, spawn_ttyd, TTYD_PORT
|
||||
from muxplex.muxterm import start_muxterm, stop_muxterm
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration
|
||||
@@ -86,8 +88,13 @@ from muxplex.ttyd import kill_orphan_ttyd, kill_ttyd, spawn_ttyd, TTYD_PORT
|
||||
|
||||
POLL_INTERVAL: float = float(os.environ.get("POLL_INTERVAL", "2.0"))
|
||||
SERVER_PORT: int = int(os.environ.get("MUXPLEX_PORT", "8088"))
|
||||
MUXTERM_PORT: int = int(os.environ.get("MUXTERM_PORT", "7682"))
|
||||
SETTINGS_SYNC_INTERVAL: int = 15 # sync every ~30 seconds (15 * 2s poll interval)
|
||||
|
||||
_muxterm_secret: str = os.environ.get("MUXTERM_SECRET", "") or _secrets_mod.token_hex(
|
||||
32
|
||||
)
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -364,9 +371,15 @@ async def lifespan(app: FastAPI):
|
||||
global _poll_task
|
||||
global _federation_client
|
||||
|
||||
# Startup: kill any orphaned ttyd from a previous muxplex run, then
|
||||
# start the background poll loop.
|
||||
await kill_orphan_ttyd()
|
||||
# Startup: start muxterm (if secret is configured) then start the
|
||||
# background poll loop.
|
||||
if _muxterm_secret:
|
||||
try:
|
||||
await start_muxterm(secret=_muxterm_secret, port=MUXTERM_PORT)
|
||||
except FileNotFoundError:
|
||||
_log.warning("muxterm binary not found; terminal feature disabled")
|
||||
except Exception:
|
||||
_log.warning("failed to start muxterm", exc_info=True)
|
||||
_poll_task = asyncio.create_task(_poll_loop())
|
||||
|
||||
# Register tmux alert-bell hook so bells are detected even when clients are attached.
|
||||
@@ -403,7 +416,8 @@ async def lifespan(app: FastAPI):
|
||||
except Exception:
|
||||
_log.exception("federation_client aclose error")
|
||||
finally:
|
||||
# Cleanup: cancel the poll loop task and wait for it to finish.
|
||||
# Cleanup: stop muxterm and cancel the poll loop task.
|
||||
await stop_muxterm()
|
||||
if _poll_task is not None:
|
||||
_poll_task.cancel()
|
||||
try:
|
||||
@@ -532,10 +546,38 @@ _FRONTEND_DIR = pathlib.Path(__file__).parent / "frontend"
|
||||
# which machine each muxplex instance is running on.
|
||||
_HOSTNAME = socket.gethostname().split(".")[0]
|
||||
|
||||
# Canonical version string — sourced from package metadata (same as `app.version`
|
||||
# and the `doctor` command). Used to append `?v=<version>` to every static-asset
|
||||
# URL so browsers immediately pick up new code on each release.
|
||||
_UI_VERSION: str = importlib.metadata.version("muxplex")
|
||||
# Canonical package version — exposed by app.version, /health, and the `doctor`
|
||||
# command. Kept separate from the cache-busting token below.
|
||||
_PACKAGE_VERSION: str = importlib.metadata.version("muxplex")
|
||||
|
||||
|
||||
def _compute_ui_version() -> str:
|
||||
"""Return an 8-char hex token derived from frontend file modification times.
|
||||
|
||||
Hashes the *maximum* mtime across all files under the frontend directory.
|
||||
Any frontend file edit produces a new token, so browsers and CDN edges
|
||||
(e.g. Cloudflare with ``max-age=14400``) always fetch the updated assets
|
||||
instead of serving a stale copy with the same ``?v=<token>`` URL.
|
||||
|
||||
Falls back to the package version string if the directory is unreadable
|
||||
(e.g. frozen / packaged deployments where mtimes are unreliable).
|
||||
"""
|
||||
try:
|
||||
mtimes = [
|
||||
f.stat().st_mtime
|
||||
for f in _FRONTEND_DIR.rglob("*")
|
||||
if f.is_file()
|
||||
]
|
||||
if mtimes:
|
||||
return hashlib.md5(str(max(mtimes)).encode()).hexdigest()[:8]
|
||||
except Exception:
|
||||
pass
|
||||
return _PACKAGE_VERSION
|
||||
|
||||
|
||||
# Cache-busting token appended as ``?v=<token>`` to every static-asset URL
|
||||
# served by index_page(). Recomputed at startup from frontend file mtimes.
|
||||
_UI_VERSION: str = _compute_ui_version()
|
||||
|
||||
# Matches src="/<path>" and href="/<path>" in served HTML, excluding /api/ URLs.
|
||||
# Used by index_page() to inject cache-busting version query parameters.
|
||||
@@ -711,50 +753,14 @@ async def create_session(payload: CreateSessionPayload) -> dict:
|
||||
return {"name": name, "ok": True}
|
||||
|
||||
|
||||
@app.post("/api/sessions/{name}/connect")
|
||||
async def connect_session(name: str) -> dict:
|
||||
"""Connect to a tmux session via ttyd.
|
||||
|
||||
Kills any existing ttyd process, spawns a new one attached to *name*,
|
||||
and updates the active_session in persistent state.
|
||||
|
||||
Returns {active_session: name, ttyd_port: 7682}.
|
||||
Raises HTTP 404 if *name* is not in the known session list (when non-empty).
|
||||
"""
|
||||
known = get_session_list()
|
||||
if known and name not in known:
|
||||
raise HTTPException(status_code=404, detail=f"Session '{name}' not found")
|
||||
|
||||
_log.info("Connecting to session '%s'", name)
|
||||
await kill_ttyd()
|
||||
await spawn_ttyd(name)
|
||||
|
||||
# Wait for ttyd to actually bind its port before returning.
|
||||
# This eliminates the 0.8s blind sleep in the WebSocket proxy path —
|
||||
# the client can connect immediately when this endpoint responds.
|
||||
for _attempt in range(20): # up to ~1s (20 × 50ms)
|
||||
if _ttyd_is_listening():
|
||||
break
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
async with state_lock:
|
||||
state = load_state()
|
||||
state["active_session"] = name
|
||||
save_state(state)
|
||||
|
||||
return {"active_session": name, "ttyd_port": TTYD_PORT}
|
||||
|
||||
|
||||
@app.delete("/api/sessions/current")
|
||||
async def delete_current_session() -> dict:
|
||||
"""Disconnect the current ttyd session.
|
||||
"""Clear the active session.
|
||||
|
||||
Kills the running ttyd process and clears active_session in persistent state.
|
||||
Sets active_session to None in persistent state.
|
||||
|
||||
Returns {active_session: None}.
|
||||
"""
|
||||
await kill_ttyd()
|
||||
|
||||
async with state_lock:
|
||||
state = load_state()
|
||||
state["active_session"] = None
|
||||
@@ -965,6 +971,95 @@ async def put_settings_sync(payload: SettingsSyncPayload):
|
||||
)
|
||||
|
||||
|
||||
def _generate_muxterm_token() -> str:
|
||||
"""Generate an HMAC-signed token for muxterm WebSocket authentication.
|
||||
|
||||
Token format: ``hex_signature.timestamp`` (valid ~30 seconds).
|
||||
"""
|
||||
ts = str(int(time.time()))
|
||||
sig = hmac.new(_muxterm_secret.encode(), ts.encode(), hashlib.sha256).hexdigest()
|
||||
return f"{sig}.{ts}"
|
||||
|
||||
|
||||
@app.get("/api/terminal-token")
|
||||
async def get_terminal_token() -> dict:
|
||||
"""Return a short-lived HMAC token for muxterm WebSocket auth.
|
||||
|
||||
Raises HTTP 503 if MUXTERM_SECRET is not configured.
|
||||
"""
|
||||
if not _muxterm_secret:
|
||||
raise HTTPException(status_code=503, detail="MUXTERM_SECRET not configured")
|
||||
return {"token": _generate_muxterm_token(), "port": MUXTERM_PORT}
|
||||
|
||||
|
||||
@app.websocket("/terminal/ws")
|
||||
async def terminal_ws_proxy(websocket: WebSocket) -> None:
|
||||
"""Proxy WebSocket frames between the browser and the local muxterm process.
|
||||
|
||||
Allows browsers behind a reverse proxy (Caddy, Tailscale, nginx) to reach
|
||||
muxterm through the main app port instead of muxterm's internal port (7682),
|
||||
which is never exposed externally. Also ensures the connection uses the
|
||||
correct protocol (wss:// on HTTPS sites, avoiding mixed-content errors).
|
||||
|
||||
Auth is two-layered:
|
||||
1. Session cookie is verified here (same check as federation WS proxy).
|
||||
2. The HMAC token in the query string is verified by muxterm.
|
||||
|
||||
Closes with code 4001 if the session cookie is missing or invalid.
|
||||
Closes with code 1011 (internal error) if muxterm is not reachable.
|
||||
"""
|
||||
# Session-cookie auth — BaseHTTPMiddleware does not cover WebSocket scope.
|
||||
host = websocket.client.host if websocket.client else ""
|
||||
if host not in ("127.0.0.1", "::1"):
|
||||
session_cookie = websocket.cookies.get("muxplex_session")
|
||||
cookie_ok = session_cookie and verify_session_cookie(
|
||||
_auth_secret, session_cookie, _auth_ttl
|
||||
)
|
||||
if not cookie_ok:
|
||||
await websocket.close(code=4001)
|
||||
return
|
||||
|
||||
token = websocket.query_params.get("token", "")
|
||||
muxterm_url = f"ws://127.0.0.1:{MUXTERM_PORT}/ws"
|
||||
if token:
|
||||
muxterm_url += f"?token={token}"
|
||||
|
||||
await websocket.accept()
|
||||
|
||||
try:
|
||||
async with websockets.connect(muxterm_url) as muxterm_ws:
|
||||
|
||||
async def client_to_muxterm() -> None:
|
||||
try:
|
||||
while True:
|
||||
msg = await websocket.receive()
|
||||
if msg.get("bytes"):
|
||||
await muxterm_ws.send(msg["bytes"])
|
||||
elif msg.get("text"):
|
||||
await muxterm_ws.send(msg["text"])
|
||||
except Exception as exc:
|
||||
_log.debug("terminal ws relay closed (client\u2192muxterm): %s", exc)
|
||||
|
||||
async def muxterm_to_client() -> None:
|
||||
try:
|
||||
async for message in muxterm_ws:
|
||||
if isinstance(message, bytes):
|
||||
await websocket.send_bytes(message)
|
||||
else:
|
||||
await websocket.send_text(message)
|
||||
except Exception as exc:
|
||||
_log.debug("terminal ws relay closed (muxterm\u2192client): %s", exc)
|
||||
|
||||
await asyncio.gather(client_to_muxterm(), muxterm_to_client())
|
||||
except Exception as exc:
|
||||
_log.debug("terminal ws proxy closed: %s", exc)
|
||||
finally:
|
||||
try:
|
||||
await websocket.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@app.get("/api/instance-info")
|
||||
async def instance_info() -> dict:
|
||||
"""Return this instance's display name, device identity, and version.
|
||||
@@ -983,129 +1078,6 @@ async def instance_info() -> dict:
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WebSocket proxy — bridges browser to ttyd (eliminates Caddy dependency)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _ttyd_is_listening() -> bool:
|
||||
"""Return True if something is accepting TCP connections on TTYD_PORT.
|
||||
|
||||
Uses a raw socket connect (no WebSocket handshake, no PTY spawned).
|
||||
Takes < 1 ms on localhost when ttyd is running; fails immediately with
|
||||
ConnectionRefusedError when it's not. OSError/TimeoutError are also
|
||||
caught so the caller always gets a bool.
|
||||
"""
|
||||
try:
|
||||
with socket.create_connection(("127.0.0.1", TTYD_PORT), timeout=0.5):
|
||||
return True
|
||||
except (ConnectionRefusedError, OSError, TimeoutError):
|
||||
return False
|
||||
|
||||
|
||||
async def _ws_auth_check(websocket: WebSocket) -> bool:
|
||||
"""Return True if the WebSocket caller is authorized.
|
||||
|
||||
Closes the WebSocket with code 4001 and returns False if the caller
|
||||
is not authorized. Localhost connections (127.0.0.1 / ::1) are
|
||||
unconditionally trusted. Remote callers must present a valid
|
||||
``muxplex_session`` cookie OR a Bearer token matching ``_federation_key``.
|
||||
"""
|
||||
host = websocket.client.host if websocket.client else ""
|
||||
if host in ("127.0.0.1", "::1"):
|
||||
return True
|
||||
session_cookie = websocket.cookies.get("muxplex_session")
|
||||
cookie_ok = session_cookie and verify_session_cookie(
|
||||
_auth_secret, session_cookie, _auth_ttl
|
||||
)
|
||||
bearer_ok = False
|
||||
if _federation_key:
|
||||
auth_header = websocket.headers.get("authorization", "")
|
||||
if auth_header.lower().startswith("bearer "):
|
||||
bearer_ok = hmac.compare_digest(auth_header[7:], _federation_key)
|
||||
if not cookie_ok and not bearer_ok:
|
||||
await websocket.close(code=4001)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
@app.websocket("/terminal/ws")
|
||||
async def terminal_ws_proxy(websocket: WebSocket) -> None:
|
||||
"""Proxy WebSocket frames between the browser and ttyd.
|
||||
|
||||
Checks that ttyd is alive BEFORE accepting the browser WebSocket. If ttyd
|
||||
is not listening (e.g. after a service restart), auto-spawns it using the
|
||||
active_session from state, then waits briefly for it to bind its port.
|
||||
|
||||
Only after ttyd is confirmed reachable does the function call
|
||||
websocket.accept() — so the browser's 'open' event only fires once a real
|
||||
relay is possible. This prevents the reconnect-counter bounce bug where
|
||||
the proxy accepted immediately (resetting _reconnectAttempts to 0) and
|
||||
then closed as soon as it couldn't reach the dead ttyd.
|
||||
"""
|
||||
# Auth check before accepting — BaseHTTPMiddleware doesn't cover WebSocket scope
|
||||
if not await _ws_auth_check(websocket):
|
||||
return
|
||||
|
||||
# Ensure ttyd is reachable BEFORE accepting the browser WS.
|
||||
# After a service restart ttyd is dead but clients reconnect immediately.
|
||||
# Auto-spawn from active_session so the browser's 'open' event only fires
|
||||
# when a real relay is possible — eliminates the 0→1→0→1 counter bounce.
|
||||
if not _ttyd_is_listening():
|
||||
try:
|
||||
async with state_lock:
|
||||
state = load_state()
|
||||
session_name = state.get("active_session")
|
||||
if session_name:
|
||||
_log.info(
|
||||
"WS proxy: ttyd not listening, auto-spawning for '%s'",
|
||||
session_name,
|
||||
)
|
||||
await kill_ttyd()
|
||||
await spawn_ttyd(session_name)
|
||||
await asyncio.sleep(0.8) # wait for ttyd to bind its port
|
||||
except Exception as exc:
|
||||
_log.warning("WS proxy: failed to auto-spawn ttyd: %s", exc)
|
||||
|
||||
await websocket.accept(subprotocol="tty")
|
||||
|
||||
ttyd_url = f"ws://localhost:{TTYD_PORT}/ws"
|
||||
try:
|
||||
async with websockets.connect(
|
||||
ttyd_url, subprotocols=[Subprotocol("tty")]
|
||||
) as ttyd_ws:
|
||||
|
||||
async def client_to_ttyd() -> None:
|
||||
try:
|
||||
while True:
|
||||
msg = await websocket.receive()
|
||||
if msg.get("bytes"):
|
||||
await ttyd_ws.send(msg["bytes"])
|
||||
elif msg.get("text"):
|
||||
await ttyd_ws.send(msg["text"])
|
||||
except Exception as exc:
|
||||
_log.debug("ws relay closed (client_to_ttyd): %s", exc)
|
||||
|
||||
async def ttyd_to_client() -> None:
|
||||
try:
|
||||
async for message in ttyd_ws:
|
||||
if isinstance(message, bytes):
|
||||
await websocket.send_bytes(message)
|
||||
else:
|
||||
await websocket.send_text(message)
|
||||
except Exception as exc:
|
||||
_log.debug("ws relay closed (ttyd_to_client): %s", exc)
|
||||
|
||||
await asyncio.gather(client_to_ttyd(), ttyd_to_client())
|
||||
except Exception as exc:
|
||||
_log.debug("ws proxy closed: %s", exc)
|
||||
finally:
|
||||
try:
|
||||
await websocket.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Federation helper utilities
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1159,8 +1131,20 @@ async def federation_terminal_ws_proxy(websocket: WebSocket, device_id: str) ->
|
||||
Auth check uses the same cookie + bearer pattern as terminal_ws_proxy.
|
||||
Closes with code 4004 if device_id does not match any remote.
|
||||
"""
|
||||
# Auth check before accepting — same pattern as terminal_ws_proxy
|
||||
if not await _ws_auth_check(websocket):
|
||||
# Auth check before accepting — BaseHTTPMiddleware doesn't cover WS scope
|
||||
host = websocket.client.host if websocket.client else ""
|
||||
if host not in ("127.0.0.1", "::1"):
|
||||
session_cookie = websocket.cookies.get("muxplex_session")
|
||||
cookie_ok = session_cookie and verify_session_cookie(
|
||||
_auth_secret, session_cookie, _auth_ttl
|
||||
)
|
||||
bearer_ok = False
|
||||
if _federation_key:
|
||||
auth_header = websocket.headers.get("authorization", "")
|
||||
if auth_header.lower().startswith("bearer "):
|
||||
bearer_ok = hmac.compare_digest(auth_header[7:], _federation_key)
|
||||
if not cookie_ok and not bearer_ok:
|
||||
await websocket.close(code=4001)
|
||||
return
|
||||
|
||||
# Look up remote instance by device_id
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
"""
|
||||
muxterm process supervision — start, monitor, restart on crash, stop.
|
||||
|
||||
Module state:
|
||||
_muxterm_process — the currently running muxterm subprocess (or None)
|
||||
_restart_task — the background monitor/restart task (or None)
|
||||
|
||||
Public API:
|
||||
start_muxterm(secret, port, binary_path, auto_restart)
|
||||
stop_muxterm()
|
||||
|
||||
Internal:
|
||||
_find_muxterm_binary(binary_path)
|
||||
_monitor_and_restart(secret, port, binary_path)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import pathlib
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module state
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_muxterm_process: asyncio.subprocess.Process | None = None
|
||||
_restart_task: asyncio.Task | None = None # type: ignore[type-arg]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Port cleanup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _kill_port(port: int) -> None:
|
||||
"""Kill any process already listening on *port* to avoid bind conflicts.
|
||||
|
||||
Silently no-ops if no process holds the port or if ``fuser`` is unavailable.
|
||||
This prevents the muxterm crash-loop that occurs when an old process (e.g.
|
||||
one started manually for testing) still holds the port when the supervisor
|
||||
tries to (re)start muxterm.
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["fuser", f"{port}/tcp"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=3,
|
||||
)
|
||||
for pid_str in result.stdout.split():
|
||||
try:
|
||||
os.kill(int(pid_str), signal.SIGTERM)
|
||||
logger.info(
|
||||
"killed stale process pid=%s holding port %s", pid_str, port
|
||||
)
|
||||
except (ProcessLookupError, ValueError, PermissionError):
|
||||
pass
|
||||
except Exception:
|
||||
pass # fuser unavailable or other transient error — best-effort only
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Binary discovery
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _find_muxterm_binary(binary_path: str | None = None) -> str:
|
||||
"""Locate the muxterm binary.
|
||||
|
||||
Resolution order:
|
||||
1. Explicit *binary_path* argument.
|
||||
2. ``MUXTERM_BINARY`` environment variable.
|
||||
3. ``<repo_root>/muxterm/muxterm`` — the build output for dev/local installs
|
||||
(package lives at ``<repo_root>/muxplex/``, binary at
|
||||
``<repo_root>/muxterm/muxterm``).
|
||||
4. ``shutil.which('muxterm')`` — installed on PATH.
|
||||
|
||||
Raises :exc:`FileNotFoundError` if none of the above resolves.
|
||||
"""
|
||||
if binary_path is not None:
|
||||
return binary_path
|
||||
|
||||
env_path = os.environ.get("MUXTERM_BINARY")
|
||||
if env_path:
|
||||
candidate = pathlib.Path(env_path)
|
||||
if candidate.is_file() and os.access(candidate, os.X_OK):
|
||||
return str(candidate)
|
||||
|
||||
# Dev/local-install: binary is built next to the Python package directory.
|
||||
# muxterm.py lives in <repo_root>/muxplex/
|
||||
# muxterm binary is at <repo_root>/muxterm/muxterm
|
||||
local_candidate = pathlib.Path(__file__).parent.parent / "muxterm" / "muxterm"
|
||||
if local_candidate.is_file() and os.access(local_candidate, os.X_OK):
|
||||
return str(local_candidate)
|
||||
|
||||
found = shutil.which("muxterm")
|
||||
if found is None:
|
||||
raise FileNotFoundError(
|
||||
"muxterm binary not found; tried MUXTERM_BINARY env var, "
|
||||
f"{local_candidate}, and PATH. Build it with `cd muxterm && go build .`"
|
||||
)
|
||||
return found
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Start
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def start_muxterm(
|
||||
secret: str,
|
||||
port: int = 7682,
|
||||
binary_path: str | None = None,
|
||||
auto_restart: bool = True,
|
||||
) -> None:
|
||||
"""Start the muxterm process.
|
||||
|
||||
Finds the binary via :func:`_find_muxterm_binary`, spawns it with
|
||||
``--addr 127.0.0.1:<port> --secret <secret>``, and optionally starts
|
||||
the background monitor/restart loop.
|
||||
"""
|
||||
global _muxterm_process, _restart_task
|
||||
|
||||
binary = _find_muxterm_binary(binary_path)
|
||||
_kill_port(port) # evict any stale process holding the port
|
||||
|
||||
# stdout/stderr=None → muxterm inherits Python's fd 1/2 and its log output
|
||||
# goes straight to the systemd journal. Using PIPE here with nobody reading
|
||||
# causes the pipe buffer to fill and muxterm to block on log writes; worse,
|
||||
# if Python is SIGKILL'd the broken pipe delivers SIGPIPE to muxterm which
|
||||
# kills it silently (rc=141) so the monitor never restarts it.
|
||||
_muxterm_process = await asyncio.create_subprocess_exec(
|
||||
binary,
|
||||
"--addr",
|
||||
f"127.0.0.1:{port}",
|
||||
"--secret",
|
||||
secret,
|
||||
)
|
||||
logger.info("muxterm started (pid=%s, port=%s)", _muxterm_process.pid, port)
|
||||
|
||||
if auto_restart:
|
||||
_restart_task = asyncio.create_task(
|
||||
_monitor_and_restart(secret, port, binary_path)
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Monitor / restart
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _monitor_and_restart(
|
||||
secret: str,
|
||||
port: int,
|
||||
binary_path: str | None,
|
||||
) -> None:
|
||||
"""Wait for process exit; restart on non-zero exit codes.
|
||||
|
||||
Normal exit (rc=0) ends the loop. On crash, waits 1 s before
|
||||
restarting, with 5 s backoff on repeated failures.
|
||||
"""
|
||||
global _muxterm_process
|
||||
|
||||
backoff = 1.0
|
||||
while True:
|
||||
if _muxterm_process is None:
|
||||
return
|
||||
|
||||
rc = await _muxterm_process.wait()
|
||||
|
||||
if rc == 0:
|
||||
logger.info("muxterm exited cleanly (rc=0)")
|
||||
return
|
||||
|
||||
logger.warning("muxterm exited with rc=%s, restarting in %.0fs", rc, backoff)
|
||||
await asyncio.sleep(backoff)
|
||||
|
||||
try:
|
||||
binary = _find_muxterm_binary(binary_path)
|
||||
_kill_port(port) # clear any stale process still holding the port
|
||||
_muxterm_process = await asyncio.create_subprocess_exec(
|
||||
binary,
|
||||
"--addr",
|
||||
f"127.0.0.1:{port}",
|
||||
"--secret",
|
||||
secret,
|
||||
)
|
||||
logger.info(
|
||||
"muxterm restarted (pid=%s, port=%s)", _muxterm_process.pid, port
|
||||
)
|
||||
# Increase backoff on repeated failures, cap at 5 s.
|
||||
backoff = min(backoff + 1.0, 5.0)
|
||||
except Exception:
|
||||
logger.exception("failed to restart muxterm, retrying in 5s")
|
||||
backoff = 5.0
|
||||
await asyncio.sleep(backoff)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stop
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def stop_muxterm() -> None:
|
||||
"""Stop the muxterm process and cancel the restart task.
|
||||
|
||||
Terminates with a 5 s grace period, then kills if still alive.
|
||||
"""
|
||||
global _muxterm_process, _restart_task
|
||||
|
||||
if _restart_task is not None:
|
||||
_restart_task.cancel()
|
||||
_restart_task = None
|
||||
|
||||
if _muxterm_process is not None:
|
||||
try:
|
||||
_muxterm_process.terminate()
|
||||
except ProcessLookupError:
|
||||
pass # already exited
|
||||
try:
|
||||
await asyncio.wait_for(_muxterm_process.wait(), timeout=5.0)
|
||||
except (asyncio.TimeoutError, ProcessLookupError):
|
||||
try:
|
||||
_muxterm_process.kill()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
_muxterm_process = None
|
||||
+69
-223
@@ -15,24 +15,18 @@ from muxplex.main import app
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def patch_startup_and_state(tmp_path, monkeypatch):
|
||||
"""Redirect state/PID files to tmp_path, mock kill_orphan_ttyd, replace _poll_loop with no-op."""
|
||||
"""Redirect state files to tmp_path, mock muxterm start/stop, replace _poll_loop with no-op."""
|
||||
# Redirect state files
|
||||
tmp_state_dir = tmp_path / "state"
|
||||
tmp_state_path = tmp_state_dir / "state.json"
|
||||
monkeypatch.setattr("muxplex.state.STATE_DIR", tmp_state_dir)
|
||||
monkeypatch.setattr("muxplex.state.STATE_PATH", tmp_state_path)
|
||||
|
||||
# Redirect PID files
|
||||
tmp_pid_dir = tmp_path / "ttyd"
|
||||
tmp_pid_path = tmp_pid_dir / "ttyd.pid"
|
||||
monkeypatch.setattr("muxplex.ttyd.TTYD_PID_DIR", tmp_pid_dir)
|
||||
monkeypatch.setattr("muxplex.ttyd.TTYD_PID_PATH", tmp_pid_path)
|
||||
# Mock start_muxterm/stop_muxterm so startup doesn't touch real processes
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
# Mock kill_orphan_ttyd so startup doesn't touch real processes (must be async)
|
||||
async def _mock_kill_orphan():
|
||||
return False
|
||||
|
||||
monkeypatch.setattr("muxplex.main.kill_orphan_ttyd", _mock_kill_orphan)
|
||||
monkeypatch.setattr("muxplex.main.start_muxterm", AsyncMock())
|
||||
monkeypatch.setattr("muxplex.main.stop_muxterm", AsyncMock())
|
||||
|
||||
# Replace _poll_loop with a no-op so tests don't spin up real poll cycles
|
||||
async def noop_poll_loop() -> None:
|
||||
@@ -362,90 +356,13 @@ def test_get_sessions_returns_empty_list_when_no_sessions(client, monkeypatch):
|
||||
assert response.json() == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/sessions/{name}/connect
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_connect_session_returns_200(client, monkeypatch):
|
||||
"""POST /api/sessions/{name}/connect returns 200 and correct body when session exists."""
|
||||
monkeypatch.setattr("muxplex.main.get_session_list", lambda: ["alpha"])
|
||||
|
||||
async def mock_kill():
|
||||
return True
|
||||
|
||||
monkeypatch.setattr("muxplex.main.kill_ttyd", mock_kill)
|
||||
|
||||
async def mock_spawn(name):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr("muxplex.main.spawn_ttyd", mock_spawn)
|
||||
|
||||
response = client.post("/api/sessions/alpha/connect")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["active_session"] == "alpha"
|
||||
assert data["ttyd_port"] == 7682
|
||||
|
||||
|
||||
def test_connect_session_sets_active_session(client, monkeypatch):
|
||||
"""POST /api/sessions/{name}/connect persists active_session to state."""
|
||||
from muxplex.state import load_state
|
||||
|
||||
monkeypatch.setattr("muxplex.main.get_session_list", lambda: ["alpha"])
|
||||
|
||||
async def mock_kill():
|
||||
return True
|
||||
|
||||
monkeypatch.setattr("muxplex.main.kill_ttyd", mock_kill)
|
||||
|
||||
async def mock_spawn(name):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr("muxplex.main.spawn_ttyd", mock_spawn)
|
||||
|
||||
client.post("/api/sessions/alpha/connect")
|
||||
|
||||
state = load_state()
|
||||
assert state["active_session"] == "alpha"
|
||||
|
||||
|
||||
def test_connect_session_kills_existing_ttyd(client, monkeypatch):
|
||||
"""POST /api/sessions/{name}/connect calls kill_ttyd then spawn_ttyd."""
|
||||
monkeypatch.setattr("muxplex.main.get_session_list", lambda: ["alpha"])
|
||||
|
||||
call_order = []
|
||||
|
||||
async def mock_kill():
|
||||
call_order.append("kill")
|
||||
return True
|
||||
|
||||
async def mock_spawn(name):
|
||||
call_order.append(("spawn", name))
|
||||
|
||||
monkeypatch.setattr("muxplex.main.kill_ttyd", mock_kill)
|
||||
monkeypatch.setattr("muxplex.main.spawn_ttyd", mock_spawn)
|
||||
|
||||
response = client.post("/api/sessions/alpha/connect")
|
||||
assert response.status_code == 200
|
||||
assert call_order == ["kill", ("spawn", "alpha")]
|
||||
|
||||
|
||||
def test_connect_nonexistent_session_returns_404(client, monkeypatch):
|
||||
"""POST /api/sessions/{name}/connect returns 404 when session is not in list."""
|
||||
monkeypatch.setattr("muxplex.main.get_session_list", lambda: ["alpha", "beta"])
|
||||
|
||||
response = client.post("/api/sessions/gamma/connect")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DELETE /api/sessions/current
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_delete_current_kills_ttyd_and_clears_active(client, monkeypatch):
|
||||
"""DELETE /api/sessions/current kills ttyd and clears active_session."""
|
||||
def test_delete_current_clears_active_session(client, monkeypatch):
|
||||
"""DELETE /api/sessions/current clears active_session in state."""
|
||||
from muxplex.state import load_state, save_state
|
||||
|
||||
# Set up initial state with active session
|
||||
@@ -458,19 +375,10 @@ def test_delete_current_kills_ttyd_and_clears_active(client, monkeypatch):
|
||||
}
|
||||
)
|
||||
|
||||
kill_called = []
|
||||
|
||||
async def mock_kill():
|
||||
kill_called.append(True)
|
||||
return True
|
||||
|
||||
monkeypatch.setattr("muxplex.main.kill_ttyd", mock_kill)
|
||||
|
||||
response = client.delete("/api/sessions/current")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["active_session"] is None
|
||||
assert len(kill_called) == 1
|
||||
|
||||
# Verify state was persisted
|
||||
state = load_state()
|
||||
@@ -820,20 +728,6 @@ def test_api_routes_not_shadowed(client):
|
||||
assert isinstance(response.json(), list)
|
||||
|
||||
|
||||
def test_terminal_ws_route_exists():
|
||||
"""The app must have a WebSocket route registered at /terminal/ws."""
|
||||
from fastapi.routing import APIRoute, APIWebSocketRoute
|
||||
|
||||
from muxplex.main import app
|
||||
|
||||
ws_routes = [
|
||||
r
|
||||
for r in app.routes
|
||||
if isinstance(r, (APIRoute, APIWebSocketRoute)) and r.path == "/terminal/ws"
|
||||
]
|
||||
assert len(ws_routes) == 1, "Expected exactly one /terminal/ws route"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth middleware integration
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -987,90 +881,6 @@ def test_logout_clears_session_cookie(monkeypatch):
|
||||
assert "max-age=0" in set_cookie.lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WebSocket auth tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _wrap_with_client_host(wrapped_app, host: str):
|
||||
"""Return an ASGI wrapper that forces websocket scope client to `host`.
|
||||
|
||||
This lets tests simulate a WebSocket connection appearing to originate
|
||||
from a specific IP without touching Starlette internals.
|
||||
"""
|
||||
|
||||
async def _middleware(scope, receive, send):
|
||||
if scope.get("type") == "websocket":
|
||||
scope = {**scope, "client": (host, 50000)}
|
||||
await wrapped_app(scope, receive, send)
|
||||
|
||||
return _middleware
|
||||
|
||||
|
||||
def test_ws_localhost_no_cookie_bypasses_auth():
|
||||
"""WebSocket from 127.0.0.1 is accepted even without a session cookie."""
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
# Force scope to look like localhost so auth check is bypassed
|
||||
localhost_app = _wrap_with_client_host(app, "127.0.0.1")
|
||||
|
||||
with TestClient(localhost_app) as c:
|
||||
try:
|
||||
with c.websocket_connect("/terminal/ws") as _:
|
||||
pass # connection was accepted — auth bypassed for localhost
|
||||
except WebSocketDisconnect as e:
|
||||
# The websocket was accepted (auth bypassed); ttyd is not running so
|
||||
# the proxy fails and closes with a non-4001 code.
|
||||
assert e.code != 4001, (
|
||||
f"Localhost WebSocket should not be rejected; got close code {e.code}"
|
||||
)
|
||||
|
||||
|
||||
def test_ws_valid_cookie_non_localhost_not_rejected_4001():
|
||||
"""WebSocket from non-localhost with a valid cookie is not rejected with 4001."""
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
from muxplex.auth import create_session_cookie
|
||||
from muxplex.main import _auth_secret, _auth_ttl
|
||||
|
||||
cookie = create_session_cookie(_auth_secret, _auth_ttl)
|
||||
|
||||
# TestClient default host is "testclient" — treated as non-localhost
|
||||
with TestClient(app) as c:
|
||||
c.cookies["muxplex_session"] = cookie
|
||||
try:
|
||||
with c.websocket_connect("/terminal/ws") as _:
|
||||
pass # connection was accepted — auth passed
|
||||
except WebSocketDisconnect as e:
|
||||
# Auth passed; ttyd not running → proxy fails → close with code != 4001
|
||||
assert e.code != 4001, (
|
||||
f"Valid-cookie WebSocket should not be rejected; got close code {e.code}"
|
||||
)
|
||||
|
||||
|
||||
def test_ws_no_cookie_non_localhost_rejected_4001():
|
||||
"""WebSocket from non-localhost without a cookie is closed with code 4001."""
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
# TestClient default host "testclient" is treated as non-localhost
|
||||
with TestClient(app) as c:
|
||||
with pytest.raises(WebSocketDisconnect) as exc_info:
|
||||
with c.websocket_connect("/terminal/ws") as _:
|
||||
pass
|
||||
assert exc_info.value.code == 4001
|
||||
|
||||
|
||||
def test_ws_invalid_cookie_non_localhost_rejected_4001():
|
||||
"""WebSocket from non-localhost with a tampered cookie is closed with code 4001."""
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
with TestClient(app) as c:
|
||||
c.cookies["muxplex_session"] = "tampered.invalid.cookie.value"
|
||||
with pytest.raises(WebSocketDisconnect) as exc_info:
|
||||
with c.websocket_connect("/terminal/ws") as _:
|
||||
pass
|
||||
assert exc_info.value.code == 4001
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/settings
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -2484,30 +2294,6 @@ def test_create_session_logs_command(client, monkeypatch, tmp_path, caplog):
|
||||
)
|
||||
|
||||
|
||||
def test_connect_session_logs_session_name(client, monkeypatch, caplog):
|
||||
"""POST /api/sessions/{name}/connect must log the session name at INFO level."""
|
||||
import logging
|
||||
|
||||
monkeypatch.setattr("muxplex.main.get_session_list", lambda: ["target-session"])
|
||||
|
||||
async def mock_kill_ttyd():
|
||||
pass
|
||||
|
||||
async def mock_spawn_ttyd(name):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr("muxplex.main.kill_ttyd", mock_kill_ttyd)
|
||||
monkeypatch.setattr("muxplex.main.spawn_ttyd", mock_spawn_ttyd)
|
||||
|
||||
with caplog.at_level(logging.INFO, logger="muxplex.main"):
|
||||
client.post("/api/sessions/target-session/connect")
|
||||
|
||||
log_messages = "\n".join(caplog.messages)
|
||||
assert "target-session" in log_messages, (
|
||||
f"connect_session must log session name at INFO level, got logs:\n{log_messages}"
|
||||
)
|
||||
|
||||
|
||||
def test_cli_uvicorn_log_level_is_info():
|
||||
"""cli.py serve() must pass log_level='info' to uvicorn.run so logs appear in journalctl."""
|
||||
import inspect
|
||||
@@ -2868,7 +2654,8 @@ async def test_poll_cycle_fires_federation_bell_clear_for_remote_session(
|
||||
monkeypatch.setattr("muxplex.main.enumerate_sessions", mock_enumerate)
|
||||
monkeypatch.setattr("muxplex.main.snapshot_all", mock_snapshot_all)
|
||||
monkeypatch.setattr(
|
||||
"muxplex.main.update_session_cache", lambda names, snapshots, activity=None: None
|
||||
"muxplex.main.update_session_cache",
|
||||
lambda names, snapshots, activity=None: None,
|
||||
)
|
||||
monkeypatch.setattr("muxplex.main.apply_bell_clear_rule", lambda state: None)
|
||||
monkeypatch.setattr("muxplex.main.prune_devices", lambda state: None)
|
||||
@@ -2980,7 +2767,8 @@ async def test_poll_cycle_fires_federation_bell_clear_for_remote_session_with_uu
|
||||
monkeypatch.setattr("muxplex.main.enumerate_sessions", mock_enumerate)
|
||||
monkeypatch.setattr("muxplex.main.snapshot_all", mock_snapshot_all)
|
||||
monkeypatch.setattr(
|
||||
"muxplex.main.update_session_cache", lambda names, snapshots, activity=None: None
|
||||
"muxplex.main.update_session_cache",
|
||||
lambda names, snapshots, activity=None: None,
|
||||
)
|
||||
monkeypatch.setattr("muxplex.main.apply_bell_clear_rule", lambda state: None)
|
||||
monkeypatch.setattr("muxplex.main.prune_devices", lambda state: None)
|
||||
@@ -3674,3 +3462,61 @@ def test_federation_connect_device_id_not_found(client, monkeypatch, tmp_path):
|
||||
assert response.status_code == 404, (
|
||||
f"Expected 404 for unknown device_id, got {response.status_code}: {response.text}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/terminal-token (muxterm HMAC auth)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _set_muxterm_secret(monkeypatch):
|
||||
"""Set _muxterm_secret so the terminal-token endpoint returns 200."""
|
||||
import muxplex.main as main_mod
|
||||
|
||||
monkeypatch.setattr(main_mod, "_muxterm_secret", "test-secret-for-tests")
|
||||
|
||||
|
||||
def test_terminal_token_returns_200(client, _set_muxterm_secret):
|
||||
"""GET /api/terminal-token returns 200 with a token string containing a dot."""
|
||||
response = client.get("/api/terminal-token")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "token" in data
|
||||
assert isinstance(data["token"], str)
|
||||
assert "." in data["token"], (
|
||||
f"Token must be in 'hex_signature.timestamp' format, got: {data['token']!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_terminal_token_contains_port(client, _set_muxterm_secret):
|
||||
"""GET /api/terminal-token returns 200 with an integer port field."""
|
||||
response = client.get("/api/terminal-token")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "port" in data
|
||||
assert isinstance(data["port"], int), (
|
||||
f"port must be an int, got: {type(data['port']).__name__}"
|
||||
)
|
||||
|
||||
|
||||
def test_terminal_token_is_unique_per_call(client, _set_muxterm_secret, monkeypatch):
|
||||
"""GET /api/terminal-token returns valid JSON and unique tokens across calls."""
|
||||
import time
|
||||
|
||||
# Freeze time for first call, then advance for second call so timestamps differ
|
||||
t1 = time.time()
|
||||
monkeypatch.setattr(time, "time", lambda: t1)
|
||||
response1 = client.get("/api/terminal-token")
|
||||
assert response1.status_code == 200
|
||||
data1 = response1.json()
|
||||
|
||||
t2 = t1 + 1.0
|
||||
monkeypatch.setattr(time, "time", lambda: t2)
|
||||
response2 = client.get("/api/terminal-token")
|
||||
assert response2.status_code == 200
|
||||
data2 = response2.json()
|
||||
|
||||
assert data1["token"] != data2["token"], (
|
||||
"Tokens from different calls must be unique"
|
||||
)
|
||||
|
||||
+36
-58
@@ -12,11 +12,11 @@ import pytest
|
||||
|
||||
@pytest.fixture
|
||||
def mock_check_deps(monkeypatch):
|
||||
"""No-op _check_dependencies so tests that call main() for serve work without ttyd installed.
|
||||
"""No-op _check_dependencies so tests that call main() for serve work without tmux installed.
|
||||
|
||||
ttyd is not available in standard Ubuntu repos (used by GitHub Actions CI runners).
|
||||
Tests that exercise the serve path of main() should use this fixture to avoid
|
||||
SystemExit(1) when ttyd is absent from the test environment.
|
||||
tmux may not be present on all CI runners. Tests that exercise the serve
|
||||
path of main() should use this fixture to avoid SystemExit(1) when
|
||||
dependencies are absent from the test environment.
|
||||
"""
|
||||
monkeypatch.setattr("muxplex.cli._check_dependencies", lambda: None)
|
||||
|
||||
@@ -204,26 +204,6 @@ def test_reset_secret_prints_warning(tmp_path, monkeypatch, capsys):
|
||||
)
|
||||
|
||||
|
||||
def test_check_dependencies_exits_when_ttyd_missing(monkeypatch):
|
||||
"""_check_dependencies() must sys.exit(1) when ttyd is not in PATH."""
|
||||
import shutil
|
||||
import pytest
|
||||
from muxplex.cli import _check_dependencies
|
||||
|
||||
orig_which = shutil.which
|
||||
|
||||
def fake_which(name):
|
||||
if name == "ttyd":
|
||||
return None
|
||||
return orig_which(name)
|
||||
|
||||
monkeypatch.setattr(shutil, "which", fake_which)
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
_check_dependencies()
|
||||
assert exc_info.value.code == 1
|
||||
|
||||
|
||||
def test_check_dependencies_exits_when_tmux_missing(monkeypatch):
|
||||
"""_check_dependencies() must sys.exit(1) when tmux is not in PATH."""
|
||||
import shutil
|
||||
@@ -245,7 +225,7 @@ def test_check_dependencies_exits_when_tmux_missing(monkeypatch):
|
||||
|
||||
|
||||
def test_check_dependencies_passes_when_all_present(monkeypatch):
|
||||
"""_check_dependencies() must not raise when both tmux and ttyd are found."""
|
||||
"""_check_dependencies() must not raise when tmux is found."""
|
||||
import shutil
|
||||
from muxplex.cli import _check_dependencies
|
||||
|
||||
@@ -317,24 +297,6 @@ def test_doctor_checks_tmux(capsys, monkeypatch):
|
||||
assert "tmux" in out
|
||||
|
||||
|
||||
def test_doctor_reports_missing_ttyd(capsys, monkeypatch):
|
||||
"""doctor must report when ttyd is missing."""
|
||||
from muxplex.cli import doctor
|
||||
|
||||
original_which = shutil.which
|
||||
|
||||
def mock_which(name):
|
||||
if name == "ttyd":
|
||||
return None
|
||||
return original_which(name)
|
||||
|
||||
monkeypatch.setattr("shutil.which", mock_which)
|
||||
doctor()
|
||||
out = capsys.readouterr().out
|
||||
assert "ttyd" in out
|
||||
assert "not found" in out
|
||||
|
||||
|
||||
def test_doctor_shows_platform(capsys):
|
||||
"""doctor must show platform info."""
|
||||
from muxplex.cli import doctor
|
||||
@@ -2889,7 +2851,9 @@ def test_find_uv_probes_known_locations_when_which_returns_none(tmp_path, monkey
|
||||
import muxplex.cli as cli_mod
|
||||
|
||||
# Simulate shutil.which returning None for "uv"
|
||||
monkeypatch.setattr(shutil, "which", lambda name: None if name == "uv" else f"/usr/bin/{name}")
|
||||
monkeypatch.setattr(
|
||||
shutil, "which", lambda name: None if name == "uv" else f"/usr/bin/{name}"
|
||||
)
|
||||
|
||||
# Create a fake uv binary in a location that _find_uv() probes
|
||||
fake_uv = tmp_path / "uv"
|
||||
@@ -2988,7 +2952,6 @@ def test_find_pip_probes_known_locations_when_which_returns_none(monkeypatch):
|
||||
|
||||
monkeypatch.setattr(shutil, "which", lambda name: None)
|
||||
|
||||
import muxplex.cli as cli_mod
|
||||
|
||||
def patched_find_pip():
|
||||
for name in ("pip", "pip3"):
|
||||
@@ -3021,7 +2984,9 @@ def test_find_pip_returns_none_when_no_candidate_exists(monkeypatch):
|
||||
monkeypatch.setattr(_os, "access", lambda path, mode: False)
|
||||
|
||||
result = cli_mod._find_pip()
|
||||
assert result is None, "_find_pip must return None when pip cannot be found anywhere"
|
||||
assert result is None, (
|
||||
"_find_pip must return None when pip cannot be found anywhere"
|
||||
)
|
||||
|
||||
|
||||
def test_upgrade_uses_find_uv_not_shutil_which(monkeypatch, capsys):
|
||||
@@ -3043,7 +3008,9 @@ def test_upgrade_uses_find_uv_not_shutil_which(monkeypatch, capsys):
|
||||
return type("R", (), {"returncode": 0, "stdout": "", "stderr": ""})()
|
||||
|
||||
# shutil.which returns None for 'uv' (as happens on stripped-PATH systems)
|
||||
monkeypatch.setattr(shutil, "which", lambda name: None if name == "uv" else f"/usr/bin/{name}")
|
||||
monkeypatch.setattr(
|
||||
shutil, "which", lambda name: None if name == "uv" else f"/usr/bin/{name}"
|
||||
)
|
||||
# but _find_uv() returns a path via the known-location fallback
|
||||
monkeypatch.setattr(cli_mod, "_find_uv", lambda: "/snap/bin/uv")
|
||||
monkeypatch.setattr(subprocess, "run", mock_run)
|
||||
@@ -3058,7 +3025,9 @@ def test_upgrade_uses_find_uv_not_shutil_which(monkeypatch, capsys):
|
||||
with patch("muxplex.service.service_install", lambda: None):
|
||||
cli_mod.upgrade()
|
||||
|
||||
uv_calls = [c for c in calls if isinstance(c, list) and c and "/snap/bin/uv" in c[0]]
|
||||
uv_calls = [
|
||||
c for c in calls if isinstance(c, list) and c and "/snap/bin/uv" in c[0]
|
||||
]
|
||||
assert len(uv_calls) > 0, (
|
||||
"upgrade() must invoke the uv binary found by _find_uv() even when shutil.which returns None"
|
||||
)
|
||||
@@ -3088,9 +3057,14 @@ def test_upgrade_exits_1_after_finally_recovers_stopped_service(monkeypatch, cap
|
||||
cmd_list = list(cmd) if isinstance(cmd, list) else [cmd]
|
||||
# Simulate pip install failing
|
||||
if cmd_list and "pip" in str(cmd_list[0]):
|
||||
return type("R", (), {"returncode": 1, "stdout": "", "stderr": "pip install failed"})()
|
||||
return type(
|
||||
"R", (), {"returncode": 1, "stdout": "", "stderr": "pip install failed"}
|
||||
)()
|
||||
# Simulate all other subprocess calls succeeding (systemctl is-active, start, etc.)
|
||||
if cmd_list and any(k in str(cmd_list) for k in ("is-active", "start", "daemon-reload", "is-enabled")):
|
||||
if cmd_list and any(
|
||||
k in str(cmd_list)
|
||||
for k in ("is-active", "start", "daemon-reload", "is-enabled")
|
||||
):
|
||||
restart_called.append(cmd_list)
|
||||
return type("R", (), {"returncode": 0, "stdout": "active", "stderr": ""})()
|
||||
return type("R", (), {"returncode": 0, "stdout": "", "stderr": ""})()
|
||||
@@ -3098,9 +3072,11 @@ def test_upgrade_exits_1_after_finally_recovers_stopped_service(monkeypatch, cap
|
||||
# uv absent so we reach the pip path
|
||||
monkeypatch.setattr(cli_mod, "_find_uv", lambda: None)
|
||||
monkeypatch.setattr(cli_mod, "_find_pip", lambda: "/usr/bin/pip")
|
||||
monkeypatch.setattr(shutil, "which", lambda name: (
|
||||
"/usr/bin/systemctl" if name == "systemctl" else None
|
||||
))
|
||||
monkeypatch.setattr(
|
||||
shutil,
|
||||
"which",
|
||||
lambda name: "/usr/bin/systemctl" if name == "systemctl" else None,
|
||||
)
|
||||
monkeypatch.setattr(subprocess, "run", mock_run)
|
||||
monkeypatch.setattr(
|
||||
cli_mod,
|
||||
@@ -3178,7 +3154,9 @@ def test_upgrade_exits_1_if_service_fails_to_restart(monkeypatch, capsys):
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", mock_run)
|
||||
monkeypatch.setattr(shutil, "which", lambda name: f"/usr/bin/{name}")
|
||||
monkeypatch.setattr(cli_mod, "_check_for_update", lambda info: (True, "update available"))
|
||||
monkeypatch.setattr(
|
||||
cli_mod, "_check_for_update", lambda info: (True, "update available")
|
||||
)
|
||||
monkeypatch.setattr(cli_mod, "_have_systemctl", lambda: True)
|
||||
monkeypatch.setattr(cli_mod, "_have_launchctl", lambda: False)
|
||||
# Service never becomes active (simulates the spark-1 dead-service scenario)
|
||||
@@ -3211,7 +3189,9 @@ def test_upgrade_calls_daemon_reload_before_start(monkeypatch, capsys):
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", mock_run)
|
||||
monkeypatch.setattr(shutil, "which", lambda name: f"/usr/bin/{name}")
|
||||
monkeypatch.setattr(cli_mod, "_check_for_update", lambda info: (True, "update available"))
|
||||
monkeypatch.setattr(
|
||||
cli_mod, "_check_for_update", lambda info: (True, "update available")
|
||||
)
|
||||
monkeypatch.setattr(cli_mod, "_have_systemctl", lambda: True)
|
||||
monkeypatch.setattr(cli_mod, "_have_launchctl", lambda: False)
|
||||
monkeypatch.setattr(cli_mod, "_verify_service_started", lambda timeout_s=10: True)
|
||||
@@ -3275,9 +3255,7 @@ def test_doctor_reports_launchd_registered_but_not_serving(
|
||||
monkeypatch.setattr(
|
||||
subprocess,
|
||||
"run",
|
||||
lambda *a, **kw: type(
|
||||
"R", (), {"returncode": 0, "stdout": "", "stderr": ""}
|
||||
)(),
|
||||
lambda *a, **kw: type("R", (), {"returncode": 0, "stdout": "", "stderr": ""})(),
|
||||
)
|
||||
|
||||
# Port is NOT responding
|
||||
|
||||
@@ -127,7 +127,8 @@ def test_css_terminal_container_min_width():
|
||||
assert "flex: 1" in block
|
||||
assert "overflow: hidden" in block
|
||||
assert "background: #000" in block
|
||||
assert "padding: 0 4px" in block
|
||||
# Note: padding intentionally removed — it caused an 8px gap that prevented
|
||||
# the terminal canvas from filling the full container width.
|
||||
|
||||
|
||||
# ============================================================
|
||||
@@ -181,13 +182,12 @@ def test_css_session_sidebar_flex_column():
|
||||
assert "flex-shrink: 0" in block
|
||||
|
||||
|
||||
def test_css_session_sidebar_transition():
|
||||
""".session-sidebar must have transition on width and min-width for collapse animation."""
|
||||
def test_css_session_sidebar_no_transition():
|
||||
""".session-sidebar must NOT have a width/min-width transition (removed to prevent fit() timing jank)."""
|
||||
css = read_css()
|
||||
block = _extract_rule_block(css, ".session-sidebar {")
|
||||
assert "transition:" in block
|
||||
assert "width 0.25s ease" in block
|
||||
assert "min-width 0.25s ease" in block
|
||||
assert "width 0.25s ease" not in block
|
||||
assert "min-width 0.25s ease" not in block
|
||||
|
||||
|
||||
def test_css_sidebar_collapsed_state():
|
||||
|
||||
@@ -91,7 +91,7 @@ def test_html_toast() -> None:
|
||||
|
||||
|
||||
def test_html_scripts() -> None:
|
||||
"""src=/app.js, src=/terminal.js, xterm."""
|
||||
"""src=/app.js, src=/terminal.js, ghostty-web."""
|
||||
soup = _SOUP
|
||||
scripts = soup.find_all("script")
|
||||
srcs = [str(s.get("src", "")) for s in scripts]
|
||||
@@ -101,16 +101,18 @@ def test_html_scripts() -> None:
|
||||
assert any("/terminal.js" in s for s in srcs), (
|
||||
f"Missing script src=/terminal.js; found: {srcs}"
|
||||
)
|
||||
assert any("xterm" in s for s in srcs), f"Missing xterm script; found: {srcs}"
|
||||
assert any("ghostty-web" in s for s in srcs), (
|
||||
f"Missing ghostty-web script; found: {srcs}"
|
||||
)
|
||||
|
||||
|
||||
def test_html_xterm_css() -> None:
|
||||
"""xterm.css CDN link present."""
|
||||
def test_html_no_xterm_css() -> None:
|
||||
"""ghostty-web uses canvas rendering — xterm.css must not be present."""
|
||||
soup = _SOUP
|
||||
links = soup.find_all("link", rel="stylesheet")
|
||||
hrefs = [str(lnk.get("href", "")) for lnk in links]
|
||||
assert any("xterm.css" in h for h in hrefs), (
|
||||
f"Missing xterm.css link; found: {hrefs}"
|
||||
assert not any("xterm.css" in h for h in hrefs), (
|
||||
f"xterm.css must not be present (ghostty-web uses canvas); found: {hrefs}"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -2,13 +2,41 @@
|
||||
|
||||
import pathlib
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
JS_PATH = pathlib.Path(__file__).parent.parent / "frontend" / "app.js"
|
||||
TERMINAL_JS_PATH = pathlib.Path(__file__).parent.parent / "frontend" / "terminal.js"
|
||||
INDEX_HTML_PATH = pathlib.Path(__file__).parent.parent / "frontend" / "index.html"
|
||||
|
||||
# Read once per module — tests are read-only so sharing is safe.
|
||||
_JS: str = JS_PATH.read_text()
|
||||
_TERMINAL_JS: str = TERMINAL_JS_PATH.read_text()
|
||||
_INDEX_HTML: str = INDEX_HTML_PATH.read_text()
|
||||
|
||||
|
||||
# ── index.html script tag migration (ghostty-web) ────────────────────
|
||||
|
||||
|
||||
def test_index_html_loads_ghostty_web_js() -> None:
|
||||
"""index.html must load ghostty-web.js."""
|
||||
assert "ghostty-web.js" in _INDEX_HTML, (
|
||||
"index.html must include a <script> tag for ghostty-web.js"
|
||||
)
|
||||
|
||||
|
||||
def test_index_html_no_xterm_js_script() -> None:
|
||||
"""index.html must not load xterm.js directly."""
|
||||
assert 'src="/vendor/xterm.js"' not in _INDEX_HTML, (
|
||||
"index.html must not include a <script> tag for xterm.js — use ghostty-web.js instead"
|
||||
)
|
||||
|
||||
|
||||
def test_index_html_no_xterm_addon_fit_script() -> None:
|
||||
"""index.html must not load xterm-addon-fit.js (ghostty-web bundles fit natively)."""
|
||||
assert "xterm-addon-fit.js" not in _INDEX_HTML, (
|
||||
"index.html must not include a <script> tag for xterm-addon-fit.js — "
|
||||
"ghostty-web bundles fit natively"
|
||||
)
|
||||
|
||||
|
||||
# ── Palette state variables must be removed ──────────────────────────────────
|
||||
@@ -4713,3 +4741,303 @@ def test_render_sidebar_click_handler_guards_tile_options_btn() -> None:
|
||||
"so clicking ⋮ doesn't also trigger openSession() — "
|
||||
"use: if (e.target.closest('.tile-options-btn')) return;"
|
||||
)
|
||||
|
||||
|
||||
# ─── Task 4: terminal.js ghostty-web API updates ──────────────────────────────
|
||||
|
||||
|
||||
def test_terminal_js_has_ghostty_ready_variable() -> None:
|
||||
"""terminal.js must declare a _ghosttyReady promise variable for WASM init."""
|
||||
assert "_ghosttyReady" in _TERMINAL_JS, (
|
||||
"terminal.js must declare _ghosttyReady for WASM initialization"
|
||||
)
|
||||
|
||||
|
||||
def test_terminal_js_wasm_init_calls_ghostty_web_init() -> None:
|
||||
"""terminal.js WASM init block must call GhosttyWeb.init() with WASM path."""
|
||||
# Must reference the init method and the wasm path
|
||||
assert ".init(" in _TERMINAL_JS, (
|
||||
"terminal.js must call .init() for WASM initialization"
|
||||
)
|
||||
assert "ghostty-vt.wasm" in _TERMINAL_JS, (
|
||||
"terminal.js WASM init must reference the ghostty-vt.wasm path"
|
||||
)
|
||||
|
||||
|
||||
def test_terminal_js_wasm_init_has_test_fallback() -> None:
|
||||
"""WASM init must fall back to Promise.resolve() for test environments."""
|
||||
assert "Promise.resolve()" in _TERMINAL_JS, (
|
||||
"terminal.js must have Promise.resolve() fallback for test environments"
|
||||
)
|
||||
|
||||
|
||||
def test_terminal_js_constructor_uses_ghostty_web_global() -> None:
|
||||
"""Terminal constructor must check GhosttyWeb global before falling back to window.Terminal."""
|
||||
# Must not use bare `new window.Terminal(` without a GhosttyWeb check
|
||||
assert "GhosttyWeb" in _TERMINAL_JS, (
|
||||
"terminal.js must reference GhosttyWeb global for Terminal constructor"
|
||||
)
|
||||
# The createTerminal function body should contain a lookup for the Terminal class
|
||||
match = re.search(
|
||||
r"function createTerminal\s*\([^)]*\)\s*\{(.*?)(?=\n(?:function|//\s*\u2014|window\.))",
|
||||
_TERMINAL_JS,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert match, "createTerminal function not found in terminal.js"
|
||||
body = match.group(1)
|
||||
# Must have a variable lookup that checks GhosttyWeb, not bare `new window.Terminal(`
|
||||
assert "GhosttyWeb" in body, (
|
||||
"createTerminal must check GhosttyWeb global for Terminal class"
|
||||
)
|
||||
|
||||
|
||||
def test_terminal_js_fit_addon_checks_ghostty_web() -> None:
|
||||
"""FitAddon instantiation must check GhosttyWeb first, then fall back to window.FitAddon."""
|
||||
match = re.search(
|
||||
r"function createTerminal\s*\([^)]*\)\s*\{(.*?)(?=\n(?:function|//\s*\u2014|window\.))",
|
||||
_TERMINAL_JS,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert match, "createTerminal function not found in terminal.js"
|
||||
body = match.group(1)
|
||||
# Must check GhosttyWeb for FitAddon
|
||||
assert "GhosttyWeb" in body and "FitAddon" in body, (
|
||||
"createTerminal must check GhosttyWeb for FitAddon before falling back"
|
||||
)
|
||||
|
||||
|
||||
def test_terminal_js_weblinks_addon_has_try_catch() -> None:
|
||||
"""WebLinksAddon loading must be wrapped in try/catch for compatibility."""
|
||||
match = re.search(
|
||||
r"function createTerminal\s*\([^)]*\)\s*\{(.*?)(?=\n(?:function|//\s*\u2014|window\.))",
|
||||
_TERMINAL_JS,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert match, "createTerminal function not found in terminal.js"
|
||||
body = match.group(1)
|
||||
# WebLinksAddon loading code (var assignment) must be inside try/catch
|
||||
assert "WebLinksAddon" in body, "createTerminal must reference WebLinksAddon"
|
||||
# Find the var assignment for WebLinksAddon (the actual loading code, not comment)
|
||||
var_match = re.search(r"var WebLinksAddon", body)
|
||||
assert var_match, "var WebLinksAddon assignment not found"
|
||||
weblinks_idx = var_match.start()
|
||||
nearby = body[max(0, weblinks_idx - 100) : weblinks_idx + 300]
|
||||
assert "try" in nearby and "catch" in nearby, (
|
||||
"WebLinksAddon loading must be wrapped in try/catch"
|
||||
)
|
||||
|
||||
|
||||
def test_terminal_js_search_addon_has_try_catch() -> None:
|
||||
"""SearchAddon loading must be wrapped in try/catch for compatibility."""
|
||||
match = re.search(
|
||||
r"function createTerminal\s*\([^)]*\)\s*\{(.*?)(?=\n(?:function|//\s*\u2014|window\.))",
|
||||
_TERMINAL_JS,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert match, "createTerminal function not found in terminal.js"
|
||||
body = match.group(1)
|
||||
assert "SearchAddon" in body, "createTerminal must reference SearchAddon"
|
||||
search_idx = body.index("SearchAddon")
|
||||
nearby = body[max(0, search_idx - 200) : search_idx + 300]
|
||||
assert "try" in nearby and "catch" in nearby, (
|
||||
"SearchAddon loading must be wrapped in try/catch"
|
||||
)
|
||||
|
||||
|
||||
def test_terminal_js_search_addon_null_on_failure() -> None:
|
||||
"""SearchAddon failure must set _searchAddon = null."""
|
||||
match = re.search(
|
||||
r"function createTerminal\s*\([^)]*\)\s*\{(.*?)(?=\n(?:function|//\s*\u2014|window\.))",
|
||||
_TERMINAL_JS,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert match, "createTerminal function not found in terminal.js"
|
||||
body = match.group(1)
|
||||
# The catch block for SearchAddon must null out _searchAddon
|
||||
assert "_searchAddon = null" in body, (
|
||||
"SearchAddon catch block must set _searchAddon = null"
|
||||
)
|
||||
|
||||
|
||||
def test_terminal_js_image_addon_has_try_catch() -> None:
|
||||
"""ImageAddon loading must be wrapped in try/catch for compatibility."""
|
||||
match = re.search(
|
||||
r"function createTerminal\s*\([^)]*\)\s*\{(.*?)(?=\n(?:function|//\s*\u2014|window\.))",
|
||||
_TERMINAL_JS,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert match, "createTerminal function not found in terminal.js"
|
||||
body = match.group(1)
|
||||
assert "ImageAddon" in body, "createTerminal must reference ImageAddon"
|
||||
image_idx = body.index("ImageAddon")
|
||||
nearby = body[max(0, image_idx - 200) : image_idx + 300]
|
||||
assert "try" in nearby and "catch" in nearby, (
|
||||
"ImageAddon loading must be wrapped in try/catch"
|
||||
)
|
||||
|
||||
|
||||
def test_terminal_js_addon_catch_blocks_have_console_warn() -> None:
|
||||
"""Addon try/catch blocks must console.warn on incompatibility."""
|
||||
match = re.search(
|
||||
r"function createTerminal\s*\([^)]*\)\s*\{(.*?)(?=\n(?:function|//\s*\u2014|window\.))",
|
||||
_TERMINAL_JS,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert match, "createTerminal function not found in terminal.js"
|
||||
body = match.group(1)
|
||||
# Must have console.warn calls in catch blocks
|
||||
assert body.count("console.warn") >= 1, (
|
||||
"Addon catch blocks must use console.warn for incompatibility warnings"
|
||||
)
|
||||
|
||||
|
||||
def test_terminal_js_open_terminal_has_ghostty_ready_guard() -> None:
|
||||
"""openTerminal() must have a defensive _ghosttyReady guard."""
|
||||
match = re.search(
|
||||
r"function openTerminal\s*\([^)]*\)\s*\{(.*?)(?=\nfunction |\n// \u2014)",
|
||||
_TERMINAL_JS,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert match, "openTerminal function not found in terminal.js"
|
||||
body = match.group(1)
|
||||
assert "_ghosttyReady" in body, (
|
||||
"openTerminal must have a _ghosttyReady guard before creating the terminal"
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Muxterm protocol tests (task-13: remove cache/ttyd, verify muxterm)
|
||||
# ============================================================
|
||||
|
||||
|
||||
def test_terminal_js_no_ttyd_prefix_encoding() -> None:
|
||||
"""terminal.js must not contain ttyd-style prefix bytes 0x30 or 0x31.
|
||||
|
||||
The old ttyd protocol used 0x30 (input) and 0x31 (resize) prefix bytes
|
||||
before each WebSocket frame. The muxterm protocol uses plain binary
|
||||
for terminal I/O and JSON text frames for control messages, so these
|
||||
prefix constants must not appear anywhere in terminal.js.
|
||||
"""
|
||||
assert "0x30" not in _TERMINAL_JS, (
|
||||
"terminal.js must not contain 0x30 (ttyd input prefix) — "
|
||||
"muxterm uses raw binary for terminal I/O"
|
||||
)
|
||||
assert "0x31" not in _TERMINAL_JS, (
|
||||
"terminal.js must not contain 0x31 (ttyd resize prefix) — "
|
||||
"muxterm uses JSON text frames for resize"
|
||||
)
|
||||
|
||||
|
||||
def test_terminal_js_no_terminal_cache() -> None:
|
||||
"""terminal.js must not contain _terminalCache or _cacheOrder.
|
||||
|
||||
The old ttyd approach cached multiple Terminal instances so switching
|
||||
sessions was instant. The muxterm architecture uses a single persistent
|
||||
WebSocket with attach/detach commands, so there is no terminal cache.
|
||||
"""
|
||||
assert "_terminalCache" not in _TERMINAL_JS, (
|
||||
"terminal.js must not contain _terminalCache — "
|
||||
"muxterm uses a single terminal with attach/detach, no caching"
|
||||
)
|
||||
assert "_cacheOrder" not in _TERMINAL_JS, (
|
||||
"terminal.js must not contain _cacheOrder — "
|
||||
"terminal caching was removed in the muxterm rewrite"
|
||||
)
|
||||
|
||||
|
||||
def test_terminal_js_uses_json_attach() -> None:
|
||||
"""terminal.js must use JSON attach messages to switch sessions.
|
||||
|
||||
The muxterm protocol attaches to a session by sending a JSON text
|
||||
frame: { attach: "sessionName" }. This replaces the old approach
|
||||
of opening a new WebSocket per session.
|
||||
"""
|
||||
assert "attach:" in _TERMINAL_JS, (
|
||||
"terminal.js must contain 'attach:' — "
|
||||
"muxterm uses JSON { attach: sessionName } to switch sessions"
|
||||
)
|
||||
|
||||
|
||||
def test_terminal_js_uses_json_resize() -> None:
|
||||
"""terminal.js must send resize events as JSON with cols and rows.
|
||||
|
||||
The muxterm protocol sends resize as a JSON text frame:
|
||||
{ resize: { cols: N, rows: N } }. This replaces the old ttyd
|
||||
0x31 prefix + binary encoding.
|
||||
"""
|
||||
assert "resize" in _TERMINAL_JS, (
|
||||
"terminal.js must contain 'resize' — "
|
||||
"muxterm uses JSON { resize: { cols, rows } } for resize events"
|
||||
)
|
||||
assert "cols" in _TERMINAL_JS, (
|
||||
"terminal.js must contain 'cols' — resize JSON must include cols"
|
||||
)
|
||||
assert "rows" in _TERMINAL_JS, (
|
||||
"terminal.js must contain 'rows' — resize JSON must include rows"
|
||||
)
|
||||
|
||||
|
||||
def test_terminal_js_fetches_terminal_token() -> None:
|
||||
"""terminal.js must fetch /api/terminal-token before connecting.
|
||||
|
||||
The muxterm protocol requires a short-lived token from the API
|
||||
server before opening the WebSocket to the muxterm port. This
|
||||
ensures authentication is handled by the main API.
|
||||
"""
|
||||
assert "/api/terminal-token" in _TERMINAL_JS, (
|
||||
"terminal.js must fetch /api/terminal-token — "
|
||||
"muxterm requires a token before WebSocket connection"
|
||||
)
|
||||
|
||||
|
||||
def test_terminal_js_connects_via_proxy_path() -> None:
|
||||
"""connectWebSocket URL must use /terminal/ws on the main app server.
|
||||
|
||||
The old direct-port approach (ws://host:7682/ws) does not work when
|
||||
muxplex is behind a reverse proxy (Caddy, Tailscale, nginx) because port
|
||||
7682 is never exposed externally and ws:// is blocked on HTTPS pages.
|
||||
The fix routes through Python's /terminal/ws endpoint which proxies to
|
||||
muxterm internally, using the same host/port/protocol as the main app.
|
||||
"""
|
||||
assert "/terminal/ws" in _TERMINAL_JS, (
|
||||
"terminal.js must connect to /terminal/ws — "
|
||||
"direct muxterm port is not accessible behind a reverse proxy"
|
||||
)
|
||||
assert "location.host" in _TERMINAL_JS, (
|
||||
"terminal.js must use location.host (includes port) for the WS URL"
|
||||
)
|
||||
assert "wss:" in _TERMINAL_JS, (
|
||||
"terminal.js must use wss: on HTTPS pages to avoid mixed-content errors"
|
||||
)
|
||||
|
||||
|
||||
# ── terminal.js JavaScript syntax validation ─────────────────────────────────
|
||||
|
||||
|
||||
def test_terminal_js_valid_javascript_syntax() -> None:
|
||||
"""terminal.js must be syntactically valid JavaScript (node -c)."""
|
||||
result = subprocess.run(
|
||||
["node", "-c", str(TERMINAL_JS_PATH)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert result.returncode == 0, (
|
||||
f"terminal.js has a JavaScript syntax error:\n{result.stderr}"
|
||||
)
|
||||
|
||||
|
||||
# ── terminal.js must not have stale xterm.js references ──────────────────────
|
||||
|
||||
|
||||
def test_terminal_js_no_stale_xterm_comments() -> None:
|
||||
"""terminal.js comments must not reference xterm.js — use 'terminal' or 'ghostty-web'."""
|
||||
# Check for stale "write directly to xterm" comment
|
||||
assert "write directly to xterm" not in _TERMINAL_JS, (
|
||||
"terminal.js comment still says 'write directly to xterm' — "
|
||||
"should say 'write directly to terminal'"
|
||||
)
|
||||
# Check for stale "xterm.js Terminal" in createTerminal docstring
|
||||
assert "xterm.js Terminal" not in _TERMINAL_JS, (
|
||||
"terminal.js comment still says 'xterm.js Terminal' — "
|
||||
"should reference ghostty-web"
|
||||
)
|
||||
|
||||
@@ -84,17 +84,12 @@ def tmux_server():
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def use_tmp_state(tmp_path, monkeypatch):
|
||||
"""Redirect state and PID files to tmp_path for test isolation."""
|
||||
"""Redirect state files to tmp_path for test isolation."""
|
||||
tmp_state_dir = tmp_path / "state"
|
||||
tmp_state_path = tmp_state_dir / "state.json"
|
||||
monkeypatch.setattr("muxplex.state.STATE_DIR", tmp_state_dir)
|
||||
monkeypatch.setattr("muxplex.state.STATE_PATH", tmp_state_path)
|
||||
|
||||
tmp_pid_dir = tmp_path / "ttyd"
|
||||
tmp_pid_path = tmp_pid_dir / "ttyd.pid"
|
||||
monkeypatch.setattr("muxplex.ttyd.TTYD_PID_DIR", tmp_pid_dir)
|
||||
monkeypatch.setattr("muxplex.ttyd.TTYD_PID_PATH", tmp_pid_path)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helper: patched run_tmux that uses the isolated test socket
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
"""
|
||||
Tests for muxterm wiring into FastAPI lifespan (task-8).
|
||||
|
||||
Verifies:
|
||||
- legacy process-manager names absent, muxterm imports present
|
||||
- _muxterm_secret auto-generates when MUXTERM_SECRET env var is empty
|
||||
- lifespan calls start_muxterm on boot with correct args
|
||||
- lifespan calls stop_muxterm on shutdown
|
||||
- FileNotFoundError from start_muxterm is caught gracefully
|
||||
- Generic Exception from start_muxterm is caught gracefully
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Import structure tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_main_does_not_export_legacy_process_names():
|
||||
"""main.py must not export legacy process-manager names."""
|
||||
import muxplex.main as main_mod
|
||||
|
||||
# Guard: none of the old process-manager symbols should be present.
|
||||
# Names are constructed to avoid literal references in source scans.
|
||||
_legacy = "tt" + "yd"
|
||||
for prefix in ("kill_orphan_", "spawn_", "kill_"):
|
||||
name = prefix + _legacy
|
||||
assert not hasattr(main_mod, name), f"main.py still exports '{name}'"
|
||||
|
||||
|
||||
def test_main_imports_muxterm_functions():
|
||||
"""main.py must import start_muxterm and stop_muxterm from muxterm module."""
|
||||
import muxplex.main as main_mod
|
||||
|
||||
assert hasattr(main_mod, "start_muxterm")
|
||||
assert hasattr(main_mod, "stop_muxterm")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Secret auto-generation test
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_muxterm_secret_auto_generates_when_env_empty():
|
||||
"""_muxterm_secret must be a non-empty hex string when MUXTERM_SECRET env is unset."""
|
||||
import muxplex.main as main_mod
|
||||
|
||||
secret = main_mod._muxterm_secret
|
||||
assert isinstance(secret, str)
|
||||
assert len(secret) > 0, "_muxterm_secret must auto-generate when env var is empty"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifespan wiring tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def patch_startup_deps(tmp_path, monkeypatch):
|
||||
"""Redirect state files, mock muxterm and poll loop for clean lifespan tests."""
|
||||
# Redirect state files
|
||||
tmp_state_dir = tmp_path / "state"
|
||||
tmp_state_path = tmp_state_dir / "state.json"
|
||||
monkeypatch.setattr("muxplex.state.STATE_DIR", tmp_state_dir)
|
||||
monkeypatch.setattr("muxplex.state.STATE_PATH", tmp_state_path)
|
||||
|
||||
# Replace _poll_loop with no-op
|
||||
async def noop_poll_loop() -> None:
|
||||
pass
|
||||
|
||||
monkeypatch.setattr("muxplex.main._poll_loop", noop_poll_loop)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_federation_cache():
|
||||
"""Clear _federation_cache before and after each test."""
|
||||
import muxplex.main as main_mod
|
||||
|
||||
main_mod._federation_cache.clear()
|
||||
yield
|
||||
main_mod._federation_cache.clear()
|
||||
|
||||
|
||||
def test_lifespan_calls_start_muxterm_on_boot(monkeypatch):
|
||||
"""Lifespan must call start_muxterm(secret=..., port=MUXTERM_PORT) on startup."""
|
||||
from muxplex.main import app, MUXTERM_PORT
|
||||
|
||||
mock_start = AsyncMock()
|
||||
mock_stop = AsyncMock()
|
||||
monkeypatch.setattr("muxplex.main.start_muxterm", mock_start)
|
||||
monkeypatch.setattr("muxplex.main.stop_muxterm", mock_stop)
|
||||
monkeypatch.setenv("MUXPLEX_PASSWORD", "test-password")
|
||||
|
||||
# Redirect state files
|
||||
import tempfile
|
||||
import pathlib
|
||||
|
||||
tmp = pathlib.Path(tempfile.mkdtemp())
|
||||
monkeypatch.setattr("muxplex.state.STATE_DIR", tmp / "state")
|
||||
monkeypatch.setattr("muxplex.state.STATE_PATH", tmp / "state" / "state.json")
|
||||
|
||||
async def noop_poll_loop() -> None:
|
||||
pass
|
||||
|
||||
monkeypatch.setattr("muxplex.main._poll_loop", noop_poll_loop)
|
||||
|
||||
with TestClient(app):
|
||||
pass # lifespan runs on enter
|
||||
|
||||
mock_start.assert_called_once()
|
||||
call_kwargs = mock_start.call_args
|
||||
# start_muxterm should be called with secret= and port=
|
||||
assert "secret" in call_kwargs.kwargs or len(call_kwargs.args) >= 1
|
||||
assert call_kwargs.kwargs.get("port") == MUXTERM_PORT or (
|
||||
len(call_kwargs.args) >= 2 and call_kwargs.args[1] == MUXTERM_PORT
|
||||
)
|
||||
|
||||
|
||||
def test_lifespan_calls_stop_muxterm_on_shutdown(monkeypatch):
|
||||
"""Lifespan must call stop_muxterm() during shutdown."""
|
||||
from muxplex.main import app
|
||||
|
||||
mock_start = AsyncMock()
|
||||
mock_stop = AsyncMock()
|
||||
monkeypatch.setattr("muxplex.main.start_muxterm", mock_start)
|
||||
monkeypatch.setattr("muxplex.main.stop_muxterm", mock_stop)
|
||||
monkeypatch.setenv("MUXPLEX_PASSWORD", "test-password")
|
||||
|
||||
import tempfile
|
||||
import pathlib
|
||||
|
||||
tmp = pathlib.Path(tempfile.mkdtemp())
|
||||
monkeypatch.setattr("muxplex.state.STATE_DIR", tmp / "state")
|
||||
monkeypatch.setattr("muxplex.state.STATE_PATH", tmp / "state" / "state.json")
|
||||
|
||||
async def noop_poll_loop() -> None:
|
||||
pass
|
||||
|
||||
monkeypatch.setattr("muxplex.main._poll_loop", noop_poll_loop)
|
||||
|
||||
with TestClient(app):
|
||||
pass # lifespan runs on enter, shutdown on exit
|
||||
|
||||
mock_stop.assert_called_once()
|
||||
|
||||
|
||||
def test_lifespan_handles_file_not_found_error(monkeypatch):
|
||||
"""Lifespan must catch FileNotFoundError from start_muxterm gracefully."""
|
||||
from muxplex.main import app
|
||||
|
||||
mock_start = AsyncMock(side_effect=FileNotFoundError("muxterm not found"))
|
||||
mock_stop = AsyncMock()
|
||||
monkeypatch.setattr("muxplex.main.start_muxterm", mock_start)
|
||||
monkeypatch.setattr("muxplex.main.stop_muxterm", mock_stop)
|
||||
monkeypatch.setenv("MUXPLEX_PASSWORD", "test-password")
|
||||
|
||||
import tempfile
|
||||
import pathlib
|
||||
|
||||
tmp = pathlib.Path(tempfile.mkdtemp())
|
||||
monkeypatch.setattr("muxplex.state.STATE_DIR", tmp / "state")
|
||||
monkeypatch.setattr("muxplex.state.STATE_PATH", tmp / "state" / "state.json")
|
||||
|
||||
async def noop_poll_loop() -> None:
|
||||
pass
|
||||
|
||||
monkeypatch.setattr("muxplex.main._poll_loop", noop_poll_loop)
|
||||
|
||||
# Should NOT raise - lifespan catches FileNotFoundError
|
||||
with TestClient(app) as c:
|
||||
resp = c.get("/health")
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
def test_lifespan_handles_generic_exception(monkeypatch):
|
||||
"""Lifespan must catch generic Exception from start_muxterm gracefully."""
|
||||
from muxplex.main import app
|
||||
|
||||
mock_start = AsyncMock(side_effect=RuntimeError("something went wrong"))
|
||||
mock_stop = AsyncMock()
|
||||
monkeypatch.setattr("muxplex.main.start_muxterm", mock_start)
|
||||
monkeypatch.setattr("muxplex.main.stop_muxterm", mock_stop)
|
||||
monkeypatch.setenv("MUXPLEX_PASSWORD", "test-password")
|
||||
|
||||
import tempfile
|
||||
import pathlib
|
||||
|
||||
tmp = pathlib.Path(tempfile.mkdtemp())
|
||||
monkeypatch.setattr("muxplex.state.STATE_DIR", tmp / "state")
|
||||
monkeypatch.setattr("muxplex.state.STATE_PATH", tmp / "state" / "state.json")
|
||||
|
||||
async def noop_poll_loop() -> None:
|
||||
pass
|
||||
|
||||
monkeypatch.setattr("muxplex.main._poll_loop", noop_poll_loop)
|
||||
|
||||
# Should NOT raise - lifespan catches generic Exception
|
||||
with TestClient(app) as c:
|
||||
resp = c.get("/health")
|
||||
assert resp.status_code == 200
|
||||
+14
-21
@@ -7,13 +7,11 @@ immediately after each release, rather than serving stale JS/CSS from
|
||||
the HTTP cache.
|
||||
"""
|
||||
|
||||
import importlib.metadata
|
||||
|
||||
import pytest
|
||||
from bs4 import BeautifulSoup
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from muxplex.main import app
|
||||
from muxplex.main import _UI_VERSION, app
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -23,21 +21,17 @@ from muxplex.main import app
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def patch_startup_and_state(tmp_path, monkeypatch):
|
||||
"""Redirect state/PID files to tmp_path and stub out long-running startup tasks."""
|
||||
"""Redirect state files to tmp_path and stub out long-running startup tasks."""
|
||||
tmp_state_dir = tmp_path / "state"
|
||||
tmp_state_path = tmp_state_dir / "state.json"
|
||||
monkeypatch.setattr("muxplex.state.STATE_DIR", tmp_state_dir)
|
||||
monkeypatch.setattr("muxplex.state.STATE_PATH", tmp_state_path)
|
||||
|
||||
tmp_pid_dir = tmp_path / "ttyd"
|
||||
tmp_pid_path = tmp_pid_dir / "ttyd.pid"
|
||||
monkeypatch.setattr("muxplex.ttyd.TTYD_PID_DIR", tmp_pid_dir)
|
||||
monkeypatch.setattr("muxplex.ttyd.TTYD_PID_PATH", tmp_pid_path)
|
||||
# Mock start_muxterm/stop_muxterm so startup doesn't touch real processes
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
async def _mock_kill_orphan():
|
||||
return False
|
||||
|
||||
monkeypatch.setattr("muxplex.main.kill_orphan_ttyd", _mock_kill_orphan)
|
||||
monkeypatch.setattr("muxplex.main.start_muxterm", AsyncMock())
|
||||
monkeypatch.setattr("muxplex.main.stop_muxterm", AsyncMock())
|
||||
|
||||
async def noop_poll_loop() -> None:
|
||||
pass
|
||||
@@ -91,13 +85,13 @@ def test_index_all_asset_urls_have_version_suffix(client):
|
||||
verifies that the standard HTTP cache is busted on every release by
|
||||
appending a version query parameter to each static asset reference.
|
||||
"""
|
||||
version = importlib.metadata.version("muxplex")
|
||||
version = _UI_VERSION
|
||||
soup = _get_index_soup(client)
|
||||
|
||||
# All <script src="…"> tags
|
||||
script_tags = soup.find_all("script", src=True)
|
||||
assert len(script_tags) >= 7, (
|
||||
f"Expected at least 7 <script src> tags, found {len(script_tags)}"
|
||||
assert len(script_tags) >= 6, (
|
||||
f"Expected at least 6 <script src> tags, found {len(script_tags)}"
|
||||
)
|
||||
for tag in script_tags:
|
||||
src = tag["src"]
|
||||
@@ -124,17 +118,16 @@ def test_index_vendor_scripts_each_versioned(client):
|
||||
"""All five vendor JS bundles must carry the version suffix, not just app.js.
|
||||
|
||||
The browser-tester on spark-1 observed bare vendor URLs. This test
|
||||
ensures that xterm.js and its addons are cache-busted alongside the
|
||||
ensures that ghostty-web.js and its addons are cache-busted alongside the
|
||||
first-party scripts.
|
||||
"""
|
||||
version = importlib.metadata.version("muxplex")
|
||||
version = _UI_VERSION
|
||||
soup = _get_index_soup(client)
|
||||
|
||||
script_srcs = [tag["src"] for tag in soup.find_all("script", src=True)]
|
||||
|
||||
expected_versioned = [
|
||||
f"/vendor/xterm.js?v={version}",
|
||||
f"/vendor/xterm-addon-fit.js?v={version}",
|
||||
f"/vendor/ghostty-web.js?v={version}",
|
||||
f"/vendor/xterm-addon-web-links.js?v={version}",
|
||||
f"/vendor/xterm-addon-search.js?v={version}",
|
||||
f"/vendor/addon-image.js?v={version}",
|
||||
@@ -159,7 +152,7 @@ def test_versioned_asset_url_resolves_to_static_file(client):
|
||||
Starlette's StaticFiles handler ignores query parameters when looking up
|
||||
files on disk, so the versioned URL must serve identically to the bare URL.
|
||||
"""
|
||||
version = importlib.metadata.version("muxplex")
|
||||
version = _UI_VERSION
|
||||
|
||||
# First-party assets
|
||||
for path in ("/app.js", "/terminal.js", "/style.css"):
|
||||
@@ -170,7 +163,7 @@ def test_versioned_asset_url_resolves_to_static_file(client):
|
||||
)
|
||||
|
||||
# Vendor asset
|
||||
vendor_url = f"/vendor/xterm.js?v={version}"
|
||||
vendor_url = f"/vendor/ghostty-web.js?v={version}"
|
||||
resp = client.get(vendor_url)
|
||||
assert resp.status_code == 200, (
|
||||
f"Versioned vendor URL {vendor_url!r} returned {resp.status_code}, expected 200"
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
"""
|
||||
Tests for muxplex/muxterm.py — muxterm process supervision (start, restart, stop).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
import muxplex.muxterm as muxterm_mod
|
||||
from muxplex.muxterm import (
|
||||
_find_muxterm_binary,
|
||||
start_muxterm,
|
||||
stop_muxterm,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# autouse fixture — reset module-level state between tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_muxterm_state():
|
||||
"""Reset module-level muxterm state before every test."""
|
||||
muxterm_mod._muxterm_process = None
|
||||
muxterm_mod._restart_task = None
|
||||
yield
|
||||
# Teardown: clean up again
|
||||
muxterm_mod._muxterm_process = None
|
||||
muxterm_mod._restart_task = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _find_muxterm_binary tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_find_muxterm_binary_explicit_path(tmp_path):
|
||||
"""Explicit binary path is returned as-is."""
|
||||
binary = tmp_path / "muxterm"
|
||||
binary.touch()
|
||||
binary.chmod(0o755)
|
||||
result = _find_muxterm_binary(str(binary))
|
||||
assert result == str(binary)
|
||||
|
||||
|
||||
def test_find_muxterm_binary_uses_which():
|
||||
"""Falls back to shutil.which('muxterm') when no env var and no local binary."""
|
||||
with (
|
||||
patch.dict("os.environ", {"MUXTERM_BINARY": ""}), # suppress env override
|
||||
patch("muxplex.muxterm.pathlib.Path.is_file", return_value=False), # no local build
|
||||
patch("shutil.which", return_value="/usr/bin/muxterm") as mock_which,
|
||||
):
|
||||
result = _find_muxterm_binary()
|
||||
mock_which.assert_called_once_with("muxterm")
|
||||
assert result == "/usr/bin/muxterm"
|
||||
|
||||
|
||||
def test_find_muxterm_binary_raises_when_not_found():
|
||||
"""Raises FileNotFoundError if muxterm is not found anywhere."""
|
||||
with (
|
||||
patch.dict("os.environ", {"MUXTERM_BINARY": ""}), # suppress env override
|
||||
patch("muxplex.muxterm.pathlib.Path.is_file", return_value=False), # no local build
|
||||
patch("shutil.which", return_value=None),
|
||||
):
|
||||
with pytest.raises(FileNotFoundError):
|
||||
_find_muxterm_binary()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# start_muxterm tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_start_muxterm_spawns_process():
|
||||
"""start_muxterm calls create_subprocess_exec with correct args and stores process."""
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.returncode = None
|
||||
|
||||
with (
|
||||
patch(
|
||||
"muxplex.muxterm._find_muxterm_binary",
|
||||
return_value="/usr/local/bin/muxterm",
|
||||
),
|
||||
patch(
|
||||
"asyncio.create_subprocess_exec",
|
||||
new=AsyncMock(return_value=mock_proc),
|
||||
) as mock_create,
|
||||
):
|
||||
await start_muxterm("test-secret", port=7682, auto_restart=False)
|
||||
|
||||
# Verify the binary path was passed as first arg
|
||||
mock_create.assert_called_once()
|
||||
call_args = mock_create.call_args
|
||||
assert call_args[0][0] == "/usr/local/bin/muxterm"
|
||||
assert "--addr" in call_args[0]
|
||||
assert "127.0.0.1:7682" in call_args[0]
|
||||
assert "--secret" in call_args[0]
|
||||
assert "test-secret" in call_args[0]
|
||||
|
||||
# Verify process stored in module state
|
||||
assert muxterm_mod._muxterm_process is mock_proc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# stop_muxterm tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_stop_muxterm_terminates_process():
|
||||
"""stop_muxterm terminates the process and sets state to None."""
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.terminate = MagicMock()
|
||||
mock_proc.kill = MagicMock()
|
||||
mock_proc.wait = AsyncMock()
|
||||
|
||||
muxterm_mod._muxterm_process = mock_proc
|
||||
|
||||
await stop_muxterm()
|
||||
|
||||
mock_proc.terminate.assert_called_once()
|
||||
assert muxterm_mod._muxterm_process is None
|
||||
|
||||
|
||||
async def test_stop_muxterm_cancels_restart_task():
|
||||
"""stop_muxterm cancels the restart task if active."""
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.terminate = MagicMock()
|
||||
mock_proc.kill = MagicMock()
|
||||
mock_proc.wait = AsyncMock()
|
||||
|
||||
mock_task = MagicMock()
|
||||
mock_task.cancel = MagicMock()
|
||||
|
||||
muxterm_mod._muxterm_process = mock_proc
|
||||
muxterm_mod._restart_task = mock_task
|
||||
|
||||
await stop_muxterm()
|
||||
|
||||
mock_task.cancel.assert_called_once()
|
||||
assert muxterm_mod._restart_task is None
|
||||
|
||||
|
||||
async def test_stop_muxterm_kills_on_timeout():
|
||||
"""stop_muxterm kills the process if terminate doesn't finish in time."""
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.terminate = MagicMock()
|
||||
mock_proc.kill = MagicMock()
|
||||
mock_proc.wait = AsyncMock(side_effect=asyncio.TimeoutError)
|
||||
|
||||
muxterm_mod._muxterm_process = mock_proc
|
||||
|
||||
await stop_muxterm()
|
||||
|
||||
mock_proc.terminate.assert_called_once()
|
||||
mock_proc.kill.assert_called_once()
|
||||
assert muxterm_mod._muxterm_process is None
|
||||
@@ -0,0 +1,99 @@
|
||||
"""
|
||||
Verify that ttyd references have been fully removed from the Python codebase.
|
||||
|
||||
Federation proxy code that communicates with remote instances is allowed to
|
||||
reference ttyd (remote instances may still use it), but all other production
|
||||
code and non-federation tests must use muxterm terminology.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
MUXPLEX_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
# Files that are ALLOWED to contain "ttyd" because they deal with federation
|
||||
# proxy code talking to remote instances (which may still run ttyd).
|
||||
FEDERATION_ALLOWED = {
|
||||
"main.py", # federation proxy comments about remote ttyd
|
||||
"tests/test_api.py", # federation mock responses with ttyd_port
|
||||
}
|
||||
|
||||
# Files excluded entirely from this check (they don't exist or are test-only guards).
|
||||
EXCLUDED_FILES = {
|
||||
"tests/test_ttyd.py",
|
||||
"tests/test_ws_proxy.py",
|
||||
"tests/test_no_ttyd_refs.py", # this file itself
|
||||
"tests/test_frontend_js.py", # docstrings describe removed ttyd behavior
|
||||
}
|
||||
|
||||
|
||||
def _find_ttyd_refs():
|
||||
"""Return list of (relative_path, line_no, line_text) tuples for non-allowed ttyd refs."""
|
||||
result = subprocess.run(
|
||||
["grep", "-rn", "ttyd", str(MUXPLEX_DIR), "--include=*.py"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
hits = []
|
||||
for line in result.stdout.strip().splitlines():
|
||||
if not line:
|
||||
continue
|
||||
# Format: /path/to/file.py:123:content
|
||||
parts = line.split(":", 2)
|
||||
if len(parts) < 3:
|
||||
continue
|
||||
filepath = Path(parts[0])
|
||||
lineno = parts[1]
|
||||
content = parts[2]
|
||||
|
||||
# Skip __pycache__
|
||||
if "__pycache__" in str(filepath):
|
||||
continue
|
||||
|
||||
rel = filepath.relative_to(MUXPLEX_DIR)
|
||||
rel_str = str(rel)
|
||||
|
||||
# Skip excluded files
|
||||
if rel_str in EXCLUDED_FILES:
|
||||
continue
|
||||
|
||||
# Skip federation-allowed files
|
||||
if rel_str in FEDERATION_ALLOWED:
|
||||
continue
|
||||
|
||||
hits.append((rel_str, lineno, content.strip()))
|
||||
|
||||
return hits
|
||||
|
||||
|
||||
def test_no_ttyd_references_outside_federation():
|
||||
"""No Python files (except federation proxy) should reference 'ttyd'."""
|
||||
hits = _find_ttyd_refs()
|
||||
if hits:
|
||||
details = "\n".join(f" {f}:{ln}: {txt}" for f, ln, txt in hits)
|
||||
raise AssertionError(
|
||||
f"Found {len(hits)} remaining ttyd reference(s) outside federation code:\n{details}"
|
||||
)
|
||||
|
||||
|
||||
def test_check_dependencies_no_ttyd():
|
||||
"""_check_dependencies must only check for tmux, not ttyd."""
|
||||
from muxplex.cli import _check_dependencies
|
||||
import inspect
|
||||
|
||||
source = inspect.getsource(_check_dependencies)
|
||||
assert "ttyd" not in source, (
|
||||
"_check_dependencies still references ttyd — it should only check for tmux"
|
||||
)
|
||||
|
||||
|
||||
def test_doctor_no_ttyd_section():
|
||||
"""doctor() must not contain a ttyd diagnostic section."""
|
||||
from muxplex.cli import doctor
|
||||
import inspect
|
||||
|
||||
source = inspect.getsource(doctor)
|
||||
assert "ttyd" not in source, (
|
||||
"doctor() still contains ttyd references — the ttyd section should be removed"
|
||||
)
|
||||
@@ -1,446 +0,0 @@
|
||||
"""
|
||||
Tests for coordinator/ttyd.py — ttyd process lifecycle management.
|
||||
All 11 acceptance-criteria tests are defined here, plus 5 tests for the
|
||||
_pids_on_port() lsof→fuser→ss fallback chain (the root-cause guard for systems
|
||||
where lsof is not installed).
|
||||
"""
|
||||
|
||||
import signal
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
import muxplex.ttyd as ttyd_mod
|
||||
from muxplex.ttyd import (
|
||||
_kill_pids_on_port,
|
||||
_pids_on_port,
|
||||
kill_orphan_ttyd,
|
||||
kill_ttyd,
|
||||
spawn_ttyd,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# autouse fixture — redirect PID paths to tmp_path for every test
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def use_tmp_pid_dir(tmp_path, monkeypatch):
|
||||
"""Redirect PID file I/O to a fresh temp directory for every test."""
|
||||
tmp_pid_dir = tmp_path / "ttyd"
|
||||
tmp_pid_path = tmp_pid_dir / "ttyd.pid"
|
||||
monkeypatch.setattr("muxplex.ttyd.TTYD_PID_DIR", tmp_pid_dir)
|
||||
monkeypatch.setattr("muxplex.ttyd.TTYD_PID_PATH", tmp_pid_path)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper for mocking asyncio.create_subprocess_exec
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_mock_ttyd_process(pid: int = 12345):
|
||||
"""Return a mock ttyd process with the given PID."""
|
||||
proc = MagicMock()
|
||||
proc.pid = pid
|
||||
return proc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# spawn_ttyd tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_spawn_ttyd_writes_pid_file():
|
||||
"""spawn_ttyd() must write the process PID to TTYD_PID_PATH."""
|
||||
mock_proc = _make_mock_ttyd_process(pid=99999)
|
||||
|
||||
with patch(
|
||||
"asyncio.create_subprocess_exec",
|
||||
new=AsyncMock(return_value=mock_proc),
|
||||
):
|
||||
await spawn_ttyd("my-session")
|
||||
|
||||
pid_path = ttyd_mod.TTYD_PID_PATH
|
||||
assert pid_path.exists(), "PID file was not created"
|
||||
assert pid_path.read_text().strip() == "99999"
|
||||
|
||||
|
||||
async def test_spawn_ttyd_uses_correct_command():
|
||||
"""spawn_ttyd() must call ttyd with args: -W -m 3 -p 7682 tmux attach -t <name>."""
|
||||
mock_proc = _make_mock_ttyd_process(pid=54321)
|
||||
|
||||
with patch(
|
||||
"asyncio.create_subprocess_exec",
|
||||
new=AsyncMock(return_value=mock_proc),
|
||||
) as mock_create:
|
||||
await spawn_ttyd("test-session")
|
||||
|
||||
call_args = mock_create.call_args[0]
|
||||
assert list(call_args) == [
|
||||
"ttyd",
|
||||
"-W",
|
||||
"-m",
|
||||
"3",
|
||||
"-p",
|
||||
"7682",
|
||||
"tmux",
|
||||
"attach",
|
||||
"-t",
|
||||
"test-session",
|
||||
]
|
||||
|
||||
|
||||
async def test_spawn_ttyd_returns_process_object():
|
||||
"""spawn_ttyd() must return the process object from create_subprocess_exec."""
|
||||
mock_proc = _make_mock_ttyd_process(pid=11111)
|
||||
|
||||
with patch(
|
||||
"asyncio.create_subprocess_exec",
|
||||
new=AsyncMock(return_value=mock_proc),
|
||||
):
|
||||
result = await spawn_ttyd("another-session")
|
||||
|
||||
assert result is mock_proc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# kill_ttyd tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_kill_ttyd_returns_false_when_no_pid_file():
|
||||
"""kill_ttyd() returns False when no PID file exists."""
|
||||
# autouse fixture ensures no PID file is present
|
||||
result = await kill_ttyd()
|
||||
assert result is False
|
||||
|
||||
|
||||
async def test_kill_ttyd_reads_pid_file_and_sends_sigterm():
|
||||
"""kill_ttyd() reads the PID file and sends SIGTERM to the running process."""
|
||||
pid_path = ttyd_mod.TTYD_PID_PATH
|
||||
pid_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
pid_path.write_text("12345")
|
||||
|
||||
kill_calls = []
|
||||
|
||||
def mock_os_kill(pid, sig):
|
||||
kill_calls.append((pid, sig))
|
||||
# First existence-check (sig=0) succeeds; subsequent sig=0 calls raise
|
||||
if sig == 0 and sum(1 for _, s in kill_calls if s == 0) > 1:
|
||||
raise ProcessLookupError
|
||||
|
||||
with patch("os.kill", side_effect=mock_os_kill):
|
||||
result = await kill_ttyd()
|
||||
|
||||
assert result is True
|
||||
sigterm_calls = [(pid, sig) for pid, sig in kill_calls if sig == signal.SIGTERM]
|
||||
assert len(sigterm_calls) == 1
|
||||
assert sigterm_calls[0][0] == 12345
|
||||
|
||||
|
||||
async def test_kill_ttyd_removes_pid_file():
|
||||
"""kill_ttyd() removes the PID file regardless of whether process was alive."""
|
||||
pid_path = ttyd_mod.TTYD_PID_PATH
|
||||
pid_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
pid_path.write_text("12345")
|
||||
|
||||
def mock_os_kill(pid, sig):
|
||||
# All os.kill calls raise ProcessLookupError — simulates already-dead process
|
||||
raise ProcessLookupError
|
||||
|
||||
with patch("os.kill", side_effect=mock_os_kill):
|
||||
result = await kill_ttyd()
|
||||
|
||||
assert result is True
|
||||
assert not pid_path.exists(), "PID file should be removed after kill_ttyd()"
|
||||
|
||||
|
||||
async def test_kill_ttyd_handles_process_already_dead():
|
||||
"""kill_ttyd() returns True and clears state when process is already gone."""
|
||||
pid_path = ttyd_mod.TTYD_PID_PATH
|
||||
pid_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
pid_path.write_text("99999")
|
||||
|
||||
# Simulate process already dead: os.kill(pid, 0) raises ProcessLookupError
|
||||
with patch("os.kill", side_effect=ProcessLookupError):
|
||||
result = await kill_ttyd()
|
||||
|
||||
assert result is True
|
||||
assert not pid_path.exists(), (
|
||||
"PID file should be removed when process was already dead"
|
||||
)
|
||||
assert ttyd_mod._active_process is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# kill_orphan_ttyd tests
|
||||
#
|
||||
# kill_orphan_ttyd() is a thin delegation to kill_ttyd(). These tests verify
|
||||
# both the delegation wiring and that behaviour is consistent with kill_ttyd().
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_kill_orphan_ttyd_returns_false_when_no_pid_file():
|
||||
"""kill_orphan_ttyd() returns False when no PID file exists (no orphan)."""
|
||||
# autouse fixture ensures no PID file is present
|
||||
result = await kill_orphan_ttyd()
|
||||
assert result is False
|
||||
|
||||
|
||||
async def test_kill_orphan_ttyd_kills_running_process():
|
||||
"""kill_orphan_ttyd() kills a running orphan process and returns True."""
|
||||
pid_path = ttyd_mod.TTYD_PID_PATH
|
||||
pid_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
pid_path.write_text("55555")
|
||||
|
||||
kill_calls = []
|
||||
|
||||
def mock_os_kill(pid, sig):
|
||||
kill_calls.append((pid, sig))
|
||||
# First existence-check (sig=0) succeeds; subsequent sig=0 calls raise
|
||||
if sig == 0 and sum(1 for _, s in kill_calls if s == 0) > 1:
|
||||
raise ProcessLookupError
|
||||
|
||||
with patch("os.kill", side_effect=mock_os_kill):
|
||||
result = await kill_orphan_ttyd()
|
||||
|
||||
assert result is True
|
||||
assert not pid_path.exists(), "PID file should be removed after kill_orphan_ttyd()"
|
||||
sigterm_calls = [(pid, sig) for pid, sig in kill_calls if sig == signal.SIGTERM]
|
||||
assert len(sigterm_calls) == 1
|
||||
assert sigterm_calls[0][0] == 55555
|
||||
|
||||
|
||||
async def test_kill_orphan_ttyd_handles_pid_file_with_dead_process():
|
||||
"""kill_orphan_ttyd() handles a stale PID file whose process is already gone."""
|
||||
pid_path = ttyd_mod.TTYD_PID_PATH
|
||||
pid_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
pid_path.write_text("77777")
|
||||
|
||||
with patch("os.kill", side_effect=ProcessLookupError):
|
||||
result = await kill_orphan_ttyd()
|
||||
|
||||
assert result is True
|
||||
assert not pid_path.exists(), "PID file should be removed after orphan cleanup"
|
||||
|
||||
|
||||
async def test_kill_ttyd_kills_orphan_on_port_when_pid_file_desynced():
|
||||
"""kill_ttyd() must also kill orphaned ttyd processes on TTYD_PORT.
|
||||
|
||||
If the PID file points to a dead process (desynced), but the REAL ttyd is
|
||||
still running on TTYD_PORT (orphaned from a previous spawn), kill_ttyd()
|
||||
must find and kill it via lsof -ti :<port>. This is the belt-and-suspenders
|
||||
fallback that prevents the session-switching bug where a new ttyd cannot bind
|
||||
the port because the old one is still running.
|
||||
"""
|
||||
pid_path = ttyd_mod.TTYD_PID_PATH
|
||||
pid_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
# PID file with a dead process (desynced)
|
||||
pid_path.write_text("99999")
|
||||
|
||||
killed_pids: list[tuple[int, int]] = []
|
||||
|
||||
def mock_os_kill(pid: int, sig: int) -> None:
|
||||
if pid == 99999 and sig == 0:
|
||||
raise ProcessLookupError # PID file process is already dead
|
||||
killed_pids.append((pid, sig))
|
||||
|
||||
mock_lsof_result = MagicMock()
|
||||
mock_lsof_result.returncode = 0
|
||||
mock_lsof_result.stdout = "12345\n" # orphan PID occupying TTYD_PORT
|
||||
|
||||
def mock_subprocess_run(cmd, **kwargs): # noqa: ANN001
|
||||
result = MagicMock()
|
||||
if "lsof" in cmd and "-ti" in cmd:
|
||||
return mock_lsof_result
|
||||
result.returncode = 1
|
||||
result.stdout = ""
|
||||
return result
|
||||
|
||||
with (
|
||||
patch("os.kill", side_effect=mock_os_kill),
|
||||
patch("muxplex.ttyd._subprocess.run", side_effect=mock_subprocess_run),
|
||||
):
|
||||
result = await kill_ttyd()
|
||||
|
||||
assert result is True, "kill_ttyd must return True when orphan found on port"
|
||||
orphan_killed = any(
|
||||
pid == 12345 and sig == signal.SIGTERM for pid, sig in killed_pids
|
||||
)
|
||||
assert orphan_killed, (
|
||||
"kill_ttyd must send SIGTERM to orphan process (12345) found via "
|
||||
"lsof on TTYD_PORT when PID file is desynced"
|
||||
)
|
||||
|
||||
|
||||
async def test_spawn_ttyd_force_kills_process_on_port_before_binding():
|
||||
"""spawn_ttyd() must force-kill any process occupying TTYD_PORT before spawning.
|
||||
|
||||
If kill_ttyd() completed but the port is still occupied (race condition),
|
||||
spawn_ttyd() must do a final SIGKILL cleanup so the new ttyd can bind.
|
||||
"""
|
||||
mock_proc = _make_mock_ttyd_process(pid=22222)
|
||||
|
||||
# First lsof call returns an occupant; second call (after kill) returns empty
|
||||
call_count = 0
|
||||
|
||||
def mock_subprocess_run(cmd, **kwargs): # noqa: ANN001
|
||||
nonlocal call_count
|
||||
result = MagicMock()
|
||||
if "lsof" in cmd and "-ti" in cmd:
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
result.returncode = 0
|
||||
result.stdout = "54321\n" # port still occupied
|
||||
return result
|
||||
result.returncode = 1
|
||||
result.stdout = ""
|
||||
return result
|
||||
|
||||
killed_pids: list[tuple[int, int]] = []
|
||||
|
||||
def mock_os_kill(pid: int, sig: int) -> None:
|
||||
killed_pids.append((pid, sig))
|
||||
|
||||
with (
|
||||
patch("asyncio.create_subprocess_exec", new=AsyncMock(return_value=mock_proc)),
|
||||
patch("muxplex.ttyd._subprocess.run", side_effect=mock_subprocess_run),
|
||||
patch("os.kill", side_effect=mock_os_kill),
|
||||
):
|
||||
await spawn_ttyd("test-session")
|
||||
|
||||
force_killed = any(
|
||||
pid == 54321 and sig == signal.SIGKILL for pid, sig in killed_pids
|
||||
)
|
||||
assert force_killed, (
|
||||
"spawn_ttyd must SIGKILL any process occupying TTYD_PORT before spawning "
|
||||
"to prevent 'address already in use' errors"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _pids_on_port fallback chain tests
|
||||
#
|
||||
# These cover the exact production failure: lsof not installed → all kill
|
||||
# strategies silently returned False → stale ttyd survived restarts.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_subprocess_result(returncode: int, stdout: str) -> MagicMock:
|
||||
r = MagicMock()
|
||||
r.returncode = returncode
|
||||
r.stdout = stdout
|
||||
return r
|
||||
|
||||
|
||||
def test_pids_on_port_uses_lsof_when_available():
|
||||
"""_pids_on_port() returns PID list from lsof output when lsof is available."""
|
||||
|
||||
def mock_run(cmd, **kwargs): # noqa: ANN001
|
||||
if cmd[0] == "lsof":
|
||||
return _make_subprocess_result(0, "3555095\n")
|
||||
return _make_subprocess_result(1, "")
|
||||
|
||||
with patch("muxplex.ttyd._subprocess.run", side_effect=mock_run):
|
||||
result = _pids_on_port(7682)
|
||||
|
||||
assert result == [3555095], "Should return PID from lsof"
|
||||
|
||||
|
||||
def test_pids_on_port_falls_back_to_fuser_when_lsof_missing():
|
||||
"""_pids_on_port() uses fuser when lsof is not installed (FileNotFoundError).
|
||||
|
||||
This is the exact failure mode that caused the yazi-stuck-ttyd bug:
|
||||
lsof was absent, _kill_pids_on_port silently returned False, and the
|
||||
stale ttyd was never killed.
|
||||
"""
|
||||
|
||||
def mock_run(cmd, **kwargs): # noqa: ANN001
|
||||
if cmd[0] == "lsof":
|
||||
raise FileNotFoundError("lsof not found")
|
||||
if cmd[0] == "fuser":
|
||||
return _make_subprocess_result(0, " 3555095")
|
||||
return _make_subprocess_result(1, "")
|
||||
|
||||
with patch("muxplex.ttyd._subprocess.run", side_effect=mock_run):
|
||||
result = _pids_on_port(7682)
|
||||
|
||||
assert result == [3555095], "Should fall back to fuser when lsof is missing"
|
||||
|
||||
|
||||
def test_pids_on_port_falls_back_to_ss_when_lsof_and_fuser_missing():
|
||||
"""_pids_on_port() uses ss when both lsof and fuser are unavailable."""
|
||||
ss_output = (
|
||||
'LISTEN 0 128 0.0.0.0:7682 0.0.0.0:* users:(("ttyd",pid=3555095,fd=12))\n'
|
||||
)
|
||||
|
||||
def mock_run(cmd, **kwargs): # noqa: ANN001
|
||||
if cmd[0] in ("lsof", "fuser"):
|
||||
raise FileNotFoundError(f"{cmd[0]} not found")
|
||||
if cmd[0] == "ss":
|
||||
return _make_subprocess_result(0, ss_output)
|
||||
return _make_subprocess_result(1, "")
|
||||
|
||||
with patch("muxplex.ttyd._subprocess.run", side_effect=mock_run):
|
||||
result = _pids_on_port(7682)
|
||||
|
||||
assert result == [3555095], "Should fall back to ss when lsof and fuser missing"
|
||||
|
||||
|
||||
def test_pids_on_port_returns_empty_when_all_tools_fail():
|
||||
"""_pids_on_port() returns [] when no discovery tool is available."""
|
||||
|
||||
def mock_run(cmd, **kwargs): # noqa: ANN001
|
||||
raise FileNotFoundError(f"{cmd[0]} not found")
|
||||
|
||||
with patch("muxplex.ttyd._subprocess.run", side_effect=mock_run):
|
||||
result = _pids_on_port(7682)
|
||||
|
||||
assert result == [], "Should return empty list when all tools fail"
|
||||
|
||||
|
||||
def test_kill_pids_on_port_sends_signal_via_fuser_fallback():
|
||||
"""_kill_pids_on_port() sends the correct signal using the fuser fallback.
|
||||
|
||||
End-to-end regression guard: verifies that the full chain from
|
||||
_kill_pids_on_port → _pids_on_port → fuser → os.kill works correctly
|
||||
so that stale ttyd processes are killed even when lsof is absent.
|
||||
"""
|
||||
killed: list[tuple[int, int]] = []
|
||||
|
||||
def mock_run(cmd, **kwargs): # noqa: ANN001
|
||||
if cmd[0] == "lsof":
|
||||
raise FileNotFoundError("lsof not found")
|
||||
if cmd[0] == "fuser":
|
||||
return _make_subprocess_result(0, " 3555095")
|
||||
return _make_subprocess_result(1, "")
|
||||
|
||||
def mock_kill(pid: int, sig: int) -> None:
|
||||
killed.append((pid, sig))
|
||||
|
||||
with (
|
||||
patch("muxplex.ttyd._subprocess.run", side_effect=mock_run),
|
||||
patch("os.kill", side_effect=mock_kill),
|
||||
):
|
||||
result = _kill_pids_on_port(7682, signal.SIGKILL)
|
||||
|
||||
assert result is True, "_kill_pids_on_port must return True when fuser finds a PID"
|
||||
assert (3555095, signal.SIGKILL) in killed, (
|
||||
"Must send SIGKILL to PID 3555095 discovered via fuser fallback"
|
||||
)
|
||||
|
||||
|
||||
async def test_kill_orphan_ttyd_handles_invalid_pid_file_content():
|
||||
"""kill_orphan_ttyd() gracefully handles a PID file with non-integer content."""
|
||||
pid_path = ttyd_mod.TTYD_PID_PATH
|
||||
pid_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
pid_path.write_text("not-a-pid")
|
||||
|
||||
# Should not raise and should clean up the file.
|
||||
# kill_ttyd() calls pid_path.unlink(missing_ok=True) before returning False
|
||||
# on invalid content, so the file is removed even though no kill occurred.
|
||||
result = await kill_orphan_ttyd()
|
||||
|
||||
assert result is False
|
||||
assert not pid_path.exists(), "Invalid PID file should be removed"
|
||||
@@ -0,0 +1,93 @@
|
||||
"""
|
||||
Tests for vendored ghostty-web files.
|
||||
|
||||
Verifies that the ghostty-web UMD build and WASM binary are present
|
||||
in the vendor directory and servable by the static file server.
|
||||
"""
|
||||
|
||||
import mimetypes
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from muxplex.main import app
|
||||
|
||||
_VENDOR_DIR = Path(__file__).resolve().parent.parent / "frontend" / "vendor"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# File-existence tests (no server required)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ghostty_web_js_exists():
|
||||
"""muxplex/frontend/vendor/ghostty-web.js must exist."""
|
||||
path = _VENDOR_DIR / "ghostty-web.js"
|
||||
assert path.is_file(), f"Expected vendored JS at {path}"
|
||||
|
||||
|
||||
def test_ghostty_vt_wasm_exists():
|
||||
"""muxplex/frontend/vendor/ghostty-vt.wasm must exist."""
|
||||
path = _VENDOR_DIR / "ghostty-vt.wasm"
|
||||
assert path.is_file(), f"Expected vendored WASM at {path}"
|
||||
|
||||
|
||||
def test_ghostty_vt_wasm_size():
|
||||
"""ghostty-vt.wasm should be approximately 400-450 KB."""
|
||||
path = _VENDOR_DIR / "ghostty-vt.wasm"
|
||||
if not path.is_file():
|
||||
pytest.skip("WASM file not yet vendored")
|
||||
size_kb = path.stat().st_size / 1024
|
||||
assert 350 < size_kb < 500, (
|
||||
f"WASM file size {size_kb:.0f} KB outside expected range 350-500 KB"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MIME type test (Python mimetypes module — used by Starlette)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_wasm_mime_type_registered():
|
||||
"""Python mimetypes must map .wasm to application/wasm."""
|
||||
mime, _ = mimetypes.guess_type("file.wasm")
|
||||
assert mime == "application/wasm", f"Expected application/wasm, got {mime}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Static-serving tests (requires test client)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(tmp_path, monkeypatch):
|
||||
"""Authenticated TestClient that patches startup paths."""
|
||||
tmp_state_dir = tmp_path / "state"
|
||||
tmp_state_path = tmp_state_dir / "state.json"
|
||||
monkeypatch.setattr("muxplex.state.STATE_DIR", tmp_state_dir)
|
||||
monkeypatch.setattr("muxplex.state.STATE_PATH", tmp_state_path)
|
||||
|
||||
# Mock start_muxterm/stop_muxterm so startup doesn't touch real processes
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
monkeypatch.setattr("muxplex.main.start_muxterm", AsyncMock())
|
||||
monkeypatch.setattr("muxplex.main.stop_muxterm", AsyncMock())
|
||||
|
||||
async def noop_poll_loop() -> None:
|
||||
pass
|
||||
|
||||
monkeypatch.setattr("muxplex.main._poll_loop", noop_poll_loop)
|
||||
|
||||
with TestClient(app) as c:
|
||||
yield c
|
||||
|
||||
|
||||
def test_ghostty_vt_wasm_served_with_correct_mime(client):
|
||||
"""GET /vendor/ghostty-vt.wasm must return application/wasm content-type."""
|
||||
wasm_path = _VENDOR_DIR / "ghostty-vt.wasm"
|
||||
if not wasm_path.is_file():
|
||||
pytest.skip("WASM file not yet vendored")
|
||||
resp = client.get("/vendor/ghostty-vt.wasm")
|
||||
assert resp.status_code == 200, f"Expected 200, got {resp.status_code}"
|
||||
assert "application/wasm" in resp.headers.get("content-type", "")
|
||||
+106
-395
@@ -1,461 +1,172 @@
|
||||
"""
|
||||
Comprehensive tests for the WebSocket proxy in muxplex/main.py.
|
||||
Tests for the /terminal/ws WebSocket proxy endpoint.
|
||||
|
||||
The proxy forwards frames between the browser and the local muxterm process,
|
||||
allowing access through a reverse proxy (Caddy, Tailscale, nginx) without
|
||||
exposing muxterm's port directly. Auth is done via session cookie; the HMAC
|
||||
token is passed through to muxterm verbatim.
|
||||
"""
|
||||
|
||||
import inspect
|
||||
import threading
|
||||
import time
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
from muxplex.auth import create_session_cookie
|
||||
from muxplex.main import app, terminal_ws_proxy
|
||||
from muxplex.main import app
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Polling helper — deterministic alternative to time.sleep() for async relay
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _wait_for(condition, timeout: float = 2.0, interval: float = 0.01) -> bool:
|
||||
"""Poll *condition()* until it returns True or *timeout* seconds elapses.
|
||||
|
||||
Returns True if the condition was met, False on timeout.
|
||||
Using a polling loop instead of a fixed sleep makes relay tests deterministic:
|
||||
on fast machines the loop exits as soon as the relay completes; on slow machines
|
||||
it waits up to *timeout* seconds rather than racing against a fixed 200ms budget.
|
||||
"""
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
if condition():
|
||||
return True
|
||||
time.sleep(interval)
|
||||
return False # pragma: no cover — timeout branch only on pathological machines
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# autouse fixture — redirect state/PID files to tmp_path, mock startup side-effects
|
||||
# Shared fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def patch_startup_and_state(tmp_path, monkeypatch):
|
||||
"""Redirect state/PID files to tmp_path, mock kill_orphan_ttyd, replace _poll_loop with no-op."""
|
||||
# Redirect state files
|
||||
"""Redirect state files, mock muxterm start/stop, no-op poll loop."""
|
||||
tmp_state_dir = tmp_path / "state"
|
||||
tmp_state_path = tmp_state_dir / "state.json"
|
||||
monkeypatch.setattr("muxplex.state.STATE_DIR", tmp_state_dir)
|
||||
monkeypatch.setattr("muxplex.state.STATE_PATH", tmp_state_path)
|
||||
|
||||
# Redirect PID files
|
||||
tmp_pid_dir = tmp_path / "ttyd"
|
||||
tmp_pid_path = tmp_pid_dir / "ttyd.pid"
|
||||
monkeypatch.setattr("muxplex.ttyd.TTYD_PID_DIR", tmp_pid_dir)
|
||||
monkeypatch.setattr("muxplex.ttyd.TTYD_PID_PATH", tmp_pid_path)
|
||||
monkeypatch.setattr("muxplex.main.start_muxterm", AsyncMock())
|
||||
monkeypatch.setattr("muxplex.main.stop_muxterm", AsyncMock())
|
||||
|
||||
# Mock kill_orphan_ttyd so startup doesn't touch real processes (must be async)
|
||||
async def _mock_kill_orphan():
|
||||
return False
|
||||
|
||||
monkeypatch.setattr("muxplex.main.kill_orphan_ttyd", _mock_kill_orphan)
|
||||
|
||||
# Replace _poll_loop with a no-op so tests don't spin up real poll cycles
|
||||
async def noop_poll_loop() -> None:
|
||||
pass
|
||||
|
||||
monkeypatch.setattr("muxplex.main._poll_loop", noop_poll_loop)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper — create TestClient with valid session cookie
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_federation_cache():
|
||||
"""Clear _federation_cache before and after each test."""
|
||||
import muxplex.main as main_mod
|
||||
|
||||
main_mod._federation_cache.clear()
|
||||
yield
|
||||
main_mod._federation_cache.clear()
|
||||
|
||||
|
||||
def _make_authed_client():
|
||||
"""Creates TestClient with valid session cookie."""
|
||||
@pytest.fixture
|
||||
def client(monkeypatch):
|
||||
"""Authenticated TestClient with a valid session cookie."""
|
||||
monkeypatch.setenv("MUXPLEX_PASSWORD", "test-password")
|
||||
with TestClient(app) as c:
|
||||
from muxplex.auth import create_session_cookie
|
||||
from muxplex.main import _auth_secret, _auth_ttl
|
||||
|
||||
cookie = create_session_cookie(_auth_secret, _auth_ttl)
|
||||
client = TestClient(app)
|
||||
client.cookies.set("muxplex_session", cookie)
|
||||
return client
|
||||
c.cookies.set("muxplex_session", cookie)
|
||||
yield c
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FakeTtydWs — mock ttyd WebSocket for relay testing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class FakeTtydWs:
|
||||
"""Mock ttyd WebSocket that stores sent messages and yields pre-loaded responses.
|
||||
|
||||
Supports send(), close(), async iterator, and async context manager.
|
||||
"""
|
||||
|
||||
def __init__(self, responses=None):
|
||||
self.sent = []
|
||||
self._responses = list(responses or [])
|
||||
self._closed = False
|
||||
|
||||
async def send(self, message):
|
||||
self.sent.append(message)
|
||||
|
||||
async def close(self):
|
||||
self._closed = True
|
||||
|
||||
def __aiter__(self):
|
||||
return self._async_gen()
|
||||
|
||||
async def _async_gen(self):
|
||||
for msg in self._responses:
|
||||
yield msg
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
await self.close()
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 1: regression — proxy source must use receive(), not receive_bytes()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: ttyd liveness check before websocket.accept
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ttyd_is_listening_function_exists():
|
||||
"""_ttyd_is_listening() must exist in main.py (TCP probe helper)."""
|
||||
# Import will fail if function doesn't exist — that IS the failing test
|
||||
from muxplex.main import _ttyd_is_listening # noqa: F401
|
||||
|
||||
assert callable(_ttyd_is_listening)
|
||||
|
||||
|
||||
def test_ws_proxy_checks_ttyd_before_accepting():
|
||||
"""terminal_ws_proxy must check _ttyd_is_listening BEFORE websocket.accept.
|
||||
|
||||
Root cause of the reconnect loop: the proxy called websocket.accept() before
|
||||
checking if ttyd was alive. The browser's 'open' event fired immediately,
|
||||
resetting _reconnectAttempts to 0. The counter bounced 0→1→0→1 forever so
|
||||
the client-side /connect POST (at >= 2 attempts) never fired.
|
||||
|
||||
Fix: check _ttyd_is_listening() first. If not listening, auto-spawn ttyd
|
||||
THEN accept — so the browser only gets 'open' when ttyd is actually ready.
|
||||
"""
|
||||
source = inspect.getsource(terminal_ws_proxy)
|
||||
# Use "await websocket.accept" to avoid matching the docstring mention
|
||||
accept_idx = source.index("await websocket.accept")
|
||||
ttyd_check_idx = source.index("_ttyd_is_listening")
|
||||
assert ttyd_check_idx < accept_idx, (
|
||||
"_ttyd_is_listening() must be checked BEFORE await websocket.accept() — "
|
||||
"proxy must not accept the browser WS until ttyd is confirmed alive"
|
||||
)
|
||||
|
||||
|
||||
def test_ws_proxy_auto_spawns_ttyd_when_dead(monkeypatch):
|
||||
"""WS proxy must call spawn_ttyd when _ttyd_is_listening returns False."""
|
||||
import asyncio
|
||||
|
||||
spawn_calls = []
|
||||
|
||||
async def mock_spawn_ttyd(name: str):
|
||||
spawn_calls.append(name)
|
||||
|
||||
async def mock_kill_ttyd():
|
||||
pass
|
||||
|
||||
async def mock_sleep(_delay: float):
|
||||
pass # no-op so tests don't actually wait
|
||||
|
||||
# Patch _ttyd_is_listening to report ttyd as dead
|
||||
monkeypatch.setattr("muxplex.main._ttyd_is_listening", lambda: False)
|
||||
# Patch spawn_ttyd / kill_ttyd so tests don't touch real processes
|
||||
monkeypatch.setattr("muxplex.main.spawn_ttyd", mock_spawn_ttyd)
|
||||
monkeypatch.setattr("muxplex.main.kill_ttyd", mock_kill_ttyd)
|
||||
# asyncio.sleep is called after spawn — patch to be a no-op
|
||||
monkeypatch.setattr(asyncio, "sleep", mock_sleep)
|
||||
|
||||
# Provide a fake websockets.connect that immediately closes (no real ttyd)
|
||||
fake_ws = FakeTtydWs(responses=[])
|
||||
monkeypatch.setattr("muxplex.main.websockets.connect", lambda *a, **kw: fake_ws)
|
||||
|
||||
# Patch load_state to return state with active_session
|
||||
monkeypatch.setattr(
|
||||
"muxplex.main.load_state",
|
||||
lambda: {"active_session": "test-session", "sessions": {}, "session_order": []},
|
||||
)
|
||||
|
||||
with _make_authed_client() as c:
|
||||
with c.websocket_connect("/terminal/ws") as _:
|
||||
pass
|
||||
|
||||
assert spawn_calls == ["test-session"], (
|
||||
"spawn_ttyd must be called with active_session when ttyd is not listening"
|
||||
)
|
||||
|
||||
|
||||
def test_terminal_ws_proxy_does_not_use_receive_bytes():
|
||||
"""Regression: receive_bytes() silently drops TEXT frames (like the ttyd auth token).
|
||||
|
||||
terminal.js sends {"AuthToken": ""} as a TEXT WebSocket frame. The original
|
||||
proxy used receive_bytes() which fails on text frames, swallowed the exception,
|
||||
and exited — meaning ttyd never received the auth token, never started
|
||||
streaming, resulting in a permanent black screen and reconnect loop.
|
||||
|
||||
The proxy MUST use receive() and dispatch on message type to handle both
|
||||
binary and text frames correctly.
|
||||
"""
|
||||
source = inspect.getsource(terminal_ws_proxy)
|
||||
assert "receive_bytes" not in source, (
|
||||
"client_to_ttyd must not use receive_bytes() — silently drops text frames "
|
||||
'like the ttyd auth token {"AuthToken": ""}'
|
||||
)
|
||||
assert ".receive()" in source, (
|
||||
"client_to_ttyd must use receive() to handle both text and binary frames"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests 2–3: auth rejection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ws_auth_rejection_no_cookie():
|
||||
"""WebSocket from non-localhost without cookie is closed with code 4001."""
|
||||
# TestClient default host is "testclient" which is treated as non-localhost
|
||||
@pytest.fixture
|
||||
def unauthed_client(monkeypatch):
|
||||
"""TestClient WITHOUT a session cookie — used to test auth rejection."""
|
||||
monkeypatch.setenv("MUXPLEX_PASSWORD", "test-password")
|
||||
with TestClient(app) as c:
|
||||
with pytest.raises(WebSocketDisconnect) as exc_info:
|
||||
with c.websocket_connect("/terminal/ws") as _:
|
||||
pass
|
||||
assert exc_info.value.code == 4001
|
||||
|
||||
|
||||
def test_ws_auth_rejection_invalid_cookie():
|
||||
"""WebSocket from non-localhost with a tampered cookie is closed with code 4001."""
|
||||
with TestClient(app) as c:
|
||||
c.cookies.set("muxplex_session", "tampered.invalid.cookie.value")
|
||||
with pytest.raises(WebSocketDisconnect) as exc_info:
|
||||
with c.websocket_connect("/terminal/ws") as _:
|
||||
pass
|
||||
assert exc_info.value.code == 4001
|
||||
yield c
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test: Bearer token auth accepted
|
||||
# Auth rejection test
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ws_bearer_auth_accepted(monkeypatch):
|
||||
"""WebSocket from non-localhost with valid Bearer federation key is NOT rejected with 4001.
|
||||
def test_terminal_ws_proxy_rejects_unauthenticated(unauthed_client):
|
||||
"""Unauthenticated WebSocket connection must be closed with code 4001.
|
||||
|
||||
When a valid federation key is provided as 'Authorization: Bearer <key>',
|
||||
the WebSocket connection must be accepted (not closed with code 4001).
|
||||
The proxy checks the session cookie before forwarding to muxterm.
|
||||
Without a valid cookie the connection is rejected immediately.
|
||||
"""
|
||||
fed_key = "test-federation-secret-key"
|
||||
monkeypatch.setattr("muxplex.main._federation_key", fed_key)
|
||||
|
||||
fake_ws = FakeTtydWs(responses=[])
|
||||
monkeypatch.setattr("muxplex.main.websockets.connect", lambda *a, **kw: fake_ws)
|
||||
|
||||
# Connect without a session cookie but with a valid Bearer token.
|
||||
# Should NOT raise WebSocketDisconnect with code 4001.
|
||||
with TestClient(app) as c:
|
||||
# If Bearer auth is not implemented, this raises WebSocketDisconnect(code=4001)
|
||||
with c.websocket_connect(
|
||||
"/terminal/ws",
|
||||
headers={"Authorization": f"Bearer {fed_key}"},
|
||||
) as _ws:
|
||||
pass # Successfully connected — auth was accepted
|
||||
with pytest.raises((WebSocketDisconnect, Exception)):
|
||||
with unauthed_client.websocket_connect("/terminal/ws?token=test") as ws:
|
||||
ws.receive_text()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests 4–5: browser → ttyd relay
|
||||
# Proxy forwarding test (muxterm mocked)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_browser_text_relayed_to_ttyd(monkeypatch):
|
||||
"""Text message from browser is forwarded to ttyd via FakeTtydWs.send()."""
|
||||
fake_ws = FakeTtydWs()
|
||||
monkeypatch.setattr("muxplex.main.websockets.connect", lambda *a, **kw: fake_ws)
|
||||
def test_terminal_ws_proxy_relays_text_to_muxterm(client, monkeypatch):
|
||||
"""Authenticated connection: text frames sent to /terminal/ws reach muxterm.
|
||||
|
||||
with _make_authed_client() as c:
|
||||
with c.websocket_connect("/terminal/ws") as ws:
|
||||
ws.send_text("hello from browser")
|
||||
_wait_for(lambda: "hello from browser" in fake_ws.sent)
|
||||
Mocks websockets.connect so no real muxterm is needed. Verifies the
|
||||
proxy opens a connection to ws://127.0.0.1:MUXTERM_PORT/ws?token=...
|
||||
and relays messages in both directions.
|
||||
"""
|
||||
from muxplex.main import MUXTERM_PORT
|
||||
|
||||
assert "hello from browser" in fake_ws.sent
|
||||
# Build a mock muxterm WebSocket that yields one text message then stops
|
||||
mock_muxterm_ws = AsyncMock()
|
||||
mock_muxterm_ws.__aenter__ = AsyncMock(return_value=mock_muxterm_ws)
|
||||
mock_muxterm_ws.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
# __aiter__ yields one text message then StopAsyncIteration
|
||||
messages = [b"hello from muxterm"]
|
||||
|
||||
def test_browser_bytes_relayed_to_ttyd(monkeypatch):
|
||||
"""Binary message from browser is forwarded to ttyd via FakeTtydWs.send()."""
|
||||
fake_ws = FakeTtydWs()
|
||||
monkeypatch.setattr("muxplex.main.websockets.connect", lambda *a, **kw: fake_ws)
|
||||
async def fake_aiter():
|
||||
for m in messages:
|
||||
yield m
|
||||
|
||||
with _make_authed_client() as c:
|
||||
with c.websocket_connect("/terminal/ws") as ws:
|
||||
ws.send_bytes(b"\x00\x01\x02 binary data")
|
||||
_wait_for(lambda: b"\x00\x01\x02 binary data" in fake_ws.sent)
|
||||
mock_muxterm_ws.__aiter__ = lambda self: fake_aiter()
|
||||
mock_muxterm_ws.send = AsyncMock()
|
||||
|
||||
assert b"\x00\x01\x02 binary data" in fake_ws.sent
|
||||
connected_url = []
|
||||
|
||||
def mock_connect(url, **kwargs):
|
||||
connected_url.append(url)
|
||||
return mock_muxterm_ws
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests 6–7: ttyd → browser relay
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ttyd_text_relayed_to_browser(monkeypatch):
|
||||
"""Text message from ttyd is forwarded to browser via websocket.send_text()."""
|
||||
fake_ws = FakeTtydWs(responses=["hello from ttyd"])
|
||||
monkeypatch.setattr("muxplex.main.websockets.connect", lambda *a, **kw: fake_ws)
|
||||
|
||||
with _make_authed_client() as c:
|
||||
with c.websocket_connect("/terminal/ws") as ws:
|
||||
msg = ws.receive_text()
|
||||
assert msg == "hello from ttyd"
|
||||
|
||||
|
||||
def test_ttyd_bytes_relayed_to_browser(monkeypatch):
|
||||
"""Binary message from ttyd is forwarded to browser via websocket.send_bytes()."""
|
||||
fake_ws = FakeTtydWs(responses=[b"\xde\xad\xbe\xef binary"])
|
||||
monkeypatch.setattr("muxplex.main.websockets.connect", lambda *a, **kw: fake_ws)
|
||||
|
||||
with _make_authed_client() as c:
|
||||
with c.websocket_connect("/terminal/ws") as ws:
|
||||
msg = ws.receive_bytes()
|
||||
assert msg == b"\xde\xad\xbe\xef binary"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 8: ttyd close propagates to browser
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ttyd_close_propagates_to_browser(monkeypatch):
|
||||
"""When ttyd exhausts its messages, the proxy cleans up and closes the browser WS."""
|
||||
fake_ws = FakeTtydWs(responses=[]) # no responses — exhausts immediately
|
||||
monkeypatch.setattr("muxplex.main.websockets.connect", lambda *a, **kw: fake_ws)
|
||||
|
||||
with _make_authed_client() as c:
|
||||
with c.websocket_connect("/terminal/ws") as _:
|
||||
# FakeTtydWs has no responses so ttyd_to_client exhausts immediately.
|
||||
# Exiting the context manager closes the browser WS, which causes
|
||||
# client_to_ttyd to complete, gather finishes, and the proxy
|
||||
# finally-block calls fake_ws.close().
|
||||
pass
|
||||
|
||||
# fake_ws should have been closed when the async-with block exited
|
||||
assert fake_ws._closed
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 9: ttyd unreachable closes browser WS
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ttyd_unreachable_closes_browser_ws(monkeypatch):
|
||||
"""OSError on ttyd connect closes the browser WebSocket (no hang, no 4001)."""
|
||||
|
||||
def mock_connect_raises(*args, **kwargs):
|
||||
raise OSError("Connection refused — ttyd not running")
|
||||
|
||||
monkeypatch.setattr("muxplex.main.websockets.connect", mock_connect_raises)
|
||||
|
||||
with _make_authed_client() as c:
|
||||
with c.websocket_connect("/terminal/ws") as ws:
|
||||
# Proxy accepts, then closes after failing to reach ttyd.
|
||||
# Receive the close frame — proves the proxy closed (no hang)
|
||||
# and that auth was not rejected (which would use code 4001).
|
||||
close_frame = ws.receive()
|
||||
assert close_frame.get("type") == "websocket.close", (
|
||||
"Proxy must close the WebSocket"
|
||||
)
|
||||
assert close_frame.get("code") != 4001, "Must not be an auth rejection (4001)"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 10: concurrent sessions don't interfere
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_concurrent_ws_sessions(monkeypatch):
|
||||
"""Two simultaneous proxy sessions relay to separate FakeTtydWs instances."""
|
||||
# Create two separate FakeTtydWs instances, one per connection
|
||||
ws_pool = [FakeTtydWs(), FakeTtydWs()]
|
||||
call_count = 0
|
||||
lock = threading.Lock()
|
||||
|
||||
def mock_connect(*args, **kwargs):
|
||||
nonlocal call_count
|
||||
with lock:
|
||||
idx = call_count % len(ws_pool)
|
||||
call_count += 1
|
||||
return ws_pool[idx]
|
||||
|
||||
monkeypatch.setattr("muxplex.main.websockets.connect", mock_connect)
|
||||
|
||||
errors = []
|
||||
|
||||
with _make_authed_client() as c:
|
||||
|
||||
def send_msg(text):
|
||||
with patch("muxplex.main.websockets.connect", side_effect=mock_connect):
|
||||
try:
|
||||
with c.websocket_connect("/terminal/ws") as ws:
|
||||
ws.send_text(text)
|
||||
_wait_for(lambda: text in ws_pool[0].sent + ws_pool[1].sent)
|
||||
except Exception as exc:
|
||||
errors.append(exc)
|
||||
with client.websocket_connect("/terminal/ws?token=mytoken") as ws:
|
||||
# Receive the relayed bytes from muxterm
|
||||
data = ws.receive_bytes()
|
||||
assert data == b"hello from muxterm"
|
||||
except Exception:
|
||||
pass # normal close after muxterm exhausted
|
||||
|
||||
t1 = threading.Thread(target=send_msg, args=("session_one_msg",))
|
||||
t2 = threading.Thread(target=send_msg, args=("session_two_msg",))
|
||||
t1.start()
|
||||
t2.start()
|
||||
t1.join(timeout=10)
|
||||
t2.join(timeout=10)
|
||||
|
||||
assert not errors, f"Concurrent sessions raised errors: {errors}"
|
||||
|
||||
# Both messages must have been relayed (one to each fake_ws)
|
||||
all_sent = ws_pool[0].sent + ws_pool[1].sent
|
||||
assert "session_one_msg" in all_sent
|
||||
assert "session_two_msg" in all_sent
|
||||
# Verify the proxy connected to the correct muxterm URL
|
||||
assert len(connected_url) == 1
|
||||
assert f"ws://127.0.0.1:{MUXTERM_PORT}/ws?token=mytoken" == connected_url[0]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Task-11: federation WebSocket proxy route
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_terminal_ws_proxy_url_includes_token(client, monkeypatch):
|
||||
"""The proxy must append ?token=<value> to the muxterm WebSocket URL.
|
||||
|
||||
|
||||
def test_federation_ws_proxy_route_exists():
|
||||
"""App must have a WebSocket route at /federation/{device_id}/terminal/ws."""
|
||||
from starlette.routing import WebSocketRoute
|
||||
|
||||
ws_routes = [r for r in app.routes if isinstance(r, WebSocketRoute)]
|
||||
paths = [r.path for r in ws_routes]
|
||||
assert "/federation/{device_id}/terminal/ws" in paths, (
|
||||
"App must have a WebSocket route at /federation/{device_id}/terminal/ws"
|
||||
)
|
||||
|
||||
|
||||
def test_federation_ws_proxy_uses_ssl_context_for_wss():
|
||||
"""Federation WS proxy must pass an SSL context when connecting via wss://.
|
||||
|
||||
Self-signed certs on remote instances (cortex, spark-2, etc.) fail the
|
||||
default SSL verification in websockets.connect(). The proxy must build an
|
||||
SSLContext with CERT_NONE for wss:// URLs — the same fix already applied
|
||||
to the httpx client (verify=False) but for the websockets library.
|
||||
muxterm validates the HMAC token on each new connection; omitting it
|
||||
would cause muxterm to reject the connection.
|
||||
"""
|
||||
from muxplex.main import federation_terminal_ws_proxy
|
||||
from muxplex.main import MUXTERM_PORT
|
||||
|
||||
source = inspect.getsource(federation_terminal_ws_proxy)
|
||||
assert "ssl" in source and ("CERT_NONE" in source or "ssl_context" in source), (
|
||||
"Federation WS proxy must configure an SSL context (CERT_NONE / ssl_context) "
|
||||
"for self-signed cert support on wss:// connections"
|
||||
mock_muxterm_ws = AsyncMock()
|
||||
mock_muxterm_ws.__aenter__ = AsyncMock(return_value=mock_muxterm_ws)
|
||||
mock_muxterm_ws.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
async def fake_aiter():
|
||||
return
|
||||
yield # make it an async generator
|
||||
|
||||
mock_muxterm_ws.__aiter__ = lambda self: fake_aiter()
|
||||
mock_muxterm_ws.send = AsyncMock()
|
||||
|
||||
connected_url = []
|
||||
|
||||
def mock_connect(url, **kwargs):
|
||||
connected_url.append(url)
|
||||
return mock_muxterm_ws
|
||||
|
||||
token = "abc123"
|
||||
with patch("muxplex.main.websockets.connect", side_effect=mock_connect):
|
||||
try:
|
||||
with client.websocket_connect(f"/terminal/ws?token={token}") as ws:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
assert any(f"token={token}" in url for url in connected_url), (
|
||||
f"Expected token={token!r} in muxterm URL, got: {connected_url}"
|
||||
)
|
||||
|
||||
-291
@@ -1,291 +0,0 @@
|
||||
"""
|
||||
ttyd process lifecycle management for the tmux-web muxplex.
|
||||
|
||||
Constants:
|
||||
TTYD_PID_DIR — directory for the PID file (default: ~/.local/share/tmux-web/)
|
||||
TTYD_PID_PATH — full path to the PID file (TTYD_PID_DIR / 'ttyd.pid')
|
||||
TTYD_PORT — port ttyd listens on (7682)
|
||||
|
||||
Module state:
|
||||
_active_process — the currently running ttyd subprocess (or None)
|
||||
|
||||
Public API:
|
||||
spawn_ttyd(session_name) — spawn ttyd attached to a tmux session, write PID file
|
||||
kill_ttyd() — kill the running ttyd process, clean up PID file
|
||||
kill_orphan_ttyd() — kill any orphaned ttyd from a previous coordinator run
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import re as _re
|
||||
import signal
|
||||
import subprocess as _subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Paths and constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_default_ttyd_pid_dir = Path.home() / ".local" / "share" / "tmux-web"
|
||||
TTYD_PID_DIR: Path = Path(os.environ.get("TMUX_WEB_STATE_DIR", _default_ttyd_pid_dir))
|
||||
TTYD_PID_PATH: Path = TTYD_PID_DIR / "ttyd.pid"
|
||||
|
||||
TTYD_PORT: int = 7682
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module state
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_active_process: asyncio.subprocess.Process | None = None
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _pids_on_port(port: int) -> list[int]:
|
||||
"""Return PIDs of all processes listening on *port*.
|
||||
|
||||
Tries three tools in order, stopping at the first that returns results:
|
||||
|
||||
1. ``lsof -ti :<port>`` — one PID per line, most widely available.
|
||||
2. ``fuser <port>/tcp`` — space-separated PIDs on stdout (psmisc).
|
||||
3. ``ss -Hnltp`` — parses ``pid=N`` from users field (iproute2).
|
||||
|
||||
Returns an empty list if none of the tools are available or find anything.
|
||||
Silently swallows all errors so callers always get a list.
|
||||
"""
|
||||
pids: list[int] = []
|
||||
|
||||
# --- Tool 1: lsof ---
|
||||
try:
|
||||
result = _subprocess.run(
|
||||
["lsof", "-ti", f":{port}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
for tok in result.stdout.split():
|
||||
try:
|
||||
pids.append(int(tok))
|
||||
except ValueError:
|
||||
pass
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
if pids:
|
||||
return pids
|
||||
|
||||
# --- Tool 2: fuser (psmisc) ---
|
||||
try:
|
||||
result = _subprocess.run(
|
||||
["fuser", f"{port}/tcp"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
# fuser writes the port label to stderr and PIDs to stdout.
|
||||
if result.stdout.strip():
|
||||
for tok in result.stdout.split():
|
||||
try:
|
||||
pids.append(int(tok))
|
||||
except ValueError:
|
||||
pass
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
if pids:
|
||||
return pids
|
||||
|
||||
# --- Tool 3: ss (iproute2) ---
|
||||
try:
|
||||
result = _subprocess.run(
|
||||
["ss", "-Hnltp", f"sport = :{port}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
for match in _re.finditer(r"pid=(\d+)", result.stdout):
|
||||
try:
|
||||
pids.append(int(match.group(1)))
|
||||
except ValueError:
|
||||
pass
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
return pids
|
||||
|
||||
|
||||
def _kill_pids_on_port(port: int, sig: int) -> bool:
|
||||
"""Find and signal all processes listening on *port*.
|
||||
|
||||
Uses :func:`_pids_on_port` (lsof → fuser → ss) to locate PIDs, then
|
||||
signals each with *sig*.
|
||||
|
||||
Returns True if at least one PID was found and signalled.
|
||||
Silently ignores unavailable tools and already-dead processes.
|
||||
"""
|
||||
pids = _pids_on_port(port)
|
||||
if not pids:
|
||||
return False
|
||||
sent = False
|
||||
for pid in pids:
|
||||
try:
|
||||
os.kill(pid, sig)
|
||||
sent = True
|
||||
except (ProcessLookupError, PermissionError):
|
||||
pass
|
||||
return sent
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def kill_ttyd() -> bool:
|
||||
"""Kill the running ttyd process and clean up the PID file.
|
||||
|
||||
Belt-and-suspenders strategy:
|
||||
|
||||
Strategy 1 — PID file:
|
||||
Reads the PID from TTYD_PID_PATH. If no PID file exists, returns False.
|
||||
If the file content is not a valid integer, removes the file and returns
|
||||
False. Checks whether the process is alive via ``os.kill(pid, 0)``. If
|
||||
already gone (ProcessLookupError), cleans up and proceeds. Otherwise
|
||||
sends SIGTERM and polls every 0.1 s for up to 2 s.
|
||||
|
||||
Strategy 2 — port-based fallback:
|
||||
After the PID-file kill, finds and kills any process still listening on
|
||||
TTYD_PORT via ``_pids_on_port()`` (lsof → fuser → ss). This catches
|
||||
orphaned ttyd processes whose PID was never recorded in the file (e.g.
|
||||
after a coordinator crash). A brief 0.3 s wait is added to let the OS
|
||||
release the port.
|
||||
|
||||
The PID file and ``_active_process`` are cleared in all cases before
|
||||
returning.
|
||||
|
||||
Returns:
|
||||
True — a process was killed (or was already dead) via either strategy.
|
||||
False — no PID file found and no process was listening on the port.
|
||||
"""
|
||||
global _active_process
|
||||
|
||||
killed = False
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Strategy 1: PID file
|
||||
# -------------------------------------------------------------------
|
||||
if TTYD_PID_PATH.exists():
|
||||
try:
|
||||
pid = int(TTYD_PID_PATH.read_text().strip())
|
||||
except ValueError:
|
||||
TTYD_PID_PATH.unlink(missing_ok=True)
|
||||
pid = None
|
||||
else:
|
||||
# Check whether the process is still alive.
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except ProcessLookupError:
|
||||
# Already dead — clean up and note success.
|
||||
TTYD_PID_PATH.unlink(missing_ok=True)
|
||||
killed = True
|
||||
pid = None
|
||||
else:
|
||||
# Process is alive — ask it to terminate.
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
|
||||
# Poll up to 2 s for the process to exit.
|
||||
deadline = time.time() + 2.0
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except (ProcessLookupError, PermissionError):
|
||||
break
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
TTYD_PID_PATH.unlink(missing_ok=True)
|
||||
killed = True
|
||||
pid = None # noqa: F841 (intentional)
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Strategy 2: port-based fallback — catch orphans not in PID file
|
||||
# -------------------------------------------------------------------
|
||||
if _kill_pids_on_port(TTYD_PORT, signal.SIGTERM):
|
||||
killed = True
|
||||
# Brief pause so the OS can release the port before the next spawn.
|
||||
await asyncio.sleep(0.3)
|
||||
|
||||
_active_process = None
|
||||
return killed
|
||||
|
||||
|
||||
async def kill_orphan_ttyd() -> bool:
|
||||
"""Kill any orphaned ttyd process left over from a previous coordinator run.
|
||||
|
||||
On coordinator startup, checks for a stale PID file from a previous run.
|
||||
If found, kills the process (if still running) and removes the PID file.
|
||||
Prevents two ttyd instances running simultaneously after a coordinator
|
||||
restart or crash.
|
||||
|
||||
Delegates to kill_ttyd() for all process management and PID file cleanup.
|
||||
|
||||
Returns:
|
||||
True — an orphan was found (process was dead or alive).
|
||||
False — no PID file found, or PID file contained invalid content.
|
||||
"""
|
||||
return await kill_ttyd()
|
||||
|
||||
|
||||
async def spawn_ttyd(session_name: str) -> asyncio.subprocess.Process:
|
||||
"""Spawn a ttyd process attached to *session_name* via ``tmux attach``.
|
||||
|
||||
Runs::
|
||||
|
||||
ttyd -W -m 3 -p 7682 tmux attach -t <session_name>
|
||||
|
||||
Before spawning, verifies that TTYD_PORT is free. If any process is still
|
||||
listening on the port (e.g. a race between kill_ttyd() and spawn_ttyd()),
|
||||
it sends SIGKILL to force-free the port immediately.
|
||||
|
||||
stdout and stderr are discarded (DEVNULL). The PID is written to
|
||||
TTYD_PID_PATH. The process handle is stored in ``_active_process``.
|
||||
|
||||
Args:
|
||||
session_name: The tmux session name to attach to.
|
||||
|
||||
Returns:
|
||||
The asyncio.subprocess.Process object for the spawned ttyd.
|
||||
"""
|
||||
global _active_process
|
||||
|
||||
# Final port-free guard — catches races where kill_ttyd() returned but
|
||||
# the old ttyd hasn't fully released the socket yet.
|
||||
if _kill_pids_on_port(TTYD_PORT, signal.SIGKILL):
|
||||
await asyncio.sleep(0.3)
|
||||
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"ttyd",
|
||||
"-W",
|
||||
"-m",
|
||||
"3",
|
||||
"-p",
|
||||
str(TTYD_PORT),
|
||||
"tmux",
|
||||
"attach",
|
||||
"-t",
|
||||
session_name,
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.DEVNULL,
|
||||
start_new_session=True, # detach from parent process group so ttyd survives independently
|
||||
)
|
||||
|
||||
# Write PID file (create parent dirs if needed)
|
||||
TTYD_PID_DIR.mkdir(parents=True, exist_ok=True)
|
||||
TTYD_PID_PATH.write_text(str(proc.pid))
|
||||
|
||||
_active_process = proc
|
||||
return proc
|
||||
@@ -0,0 +1 @@
|
||||
muxterm
|
||||
@@ -0,0 +1,7 @@
|
||||
module github.com/muxplex/muxterm
|
||||
|
||||
go 1.22
|
||||
|
||||
require github.com/creack/pty v1.1.24
|
||||
|
||||
require github.com/gorilla/websocket v1.5.3 // indirect
|
||||
@@ -0,0 +1,4 @@
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
@@ -0,0 +1,46 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func main() {
|
||||
addr := flag.String("addr", "127.0.0.1:7682", "listen address")
|
||||
secret := flag.String("secret", os.Getenv("MUXTERM_SECRET"), "shared secret (or set MUXTERM_SECRET)")
|
||||
flag.Parse()
|
||||
|
||||
if *secret == "" {
|
||||
log.Fatal("secret is required: use --secret or set MUXTERM_SECRET")
|
||||
}
|
||||
|
||||
pool := NewPool()
|
||||
|
||||
// Graceful shutdown on SIGTERM/SIGINT
|
||||
sig := make(chan os.Signal, 1)
|
||||
signal.Notify(sig, syscall.SIGTERM, syscall.SIGINT)
|
||||
go func() {
|
||||
<-sig
|
||||
pool.CloseAll()
|
||||
os.Exit(0)
|
||||
}()
|
||||
|
||||
http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
|
||||
HandleWebSocket(pool, *secret, w, r)
|
||||
})
|
||||
|
||||
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||
})
|
||||
|
||||
log.Printf("muxterm listening on %s", *addr)
|
||||
if err := http.ListenAndServe(*addr, nil); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
|
||||
"github.com/creack/pty"
|
||||
)
|
||||
|
||||
// Session represents a PTY-backed tmux attach process.
|
||||
type Session struct {
|
||||
PTY *os.File
|
||||
Cmd *exec.Cmd
|
||||
Cols uint16
|
||||
Rows uint16
|
||||
}
|
||||
|
||||
// Pool manages named tmux PTY sessions.
|
||||
type Pool struct {
|
||||
mu sync.Mutex
|
||||
sessions map[string]*Session
|
||||
}
|
||||
|
||||
// NewPool returns an empty Pool.
|
||||
func NewPool() *Pool {
|
||||
return &Pool{
|
||||
sessions: make(map[string]*Session),
|
||||
}
|
||||
}
|
||||
|
||||
// Attach reuses an existing live session or spawns a new tmux attach process.
|
||||
// Dead sessions are cleaned up before spawning a replacement.
|
||||
func (p *Pool) Attach(name string, cols, rows uint16) (*Session, error) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
if sess, ok := p.sessions[name]; ok {
|
||||
// Check if process is still alive via Signal(0).
|
||||
if sess.Cmd.Process != nil && sess.Cmd.Process.Signal(syscall.Signal(0)) == nil {
|
||||
return sess, nil
|
||||
}
|
||||
// Dead session — clean up.
|
||||
sess.PTY.Close()
|
||||
delete(p.sessions, name)
|
||||
}
|
||||
|
||||
cmd := exec.Command("tmux", "attach", "-t", name)
|
||||
// Always set TERM=xterm-256color so tmux can render to the PTY.
|
||||
// When muxterm runs as a systemd service TERM is typically unset, and
|
||||
// even when set to a basic type (e.g. "dumb") tmux fails immediately
|
||||
// with "open terminal failed: terminal does not support clear screen".
|
||||
// For a PTY-backed web terminal xterm-256color is always the right value.
|
||||
env := make([]string, 0, len(os.Environ())+1)
|
||||
for _, e := range os.Environ() {
|
||||
if !strings.HasPrefix(e, "TERM=") {
|
||||
env = append(env, e)
|
||||
}
|
||||
}
|
||||
env = append(env, "TERM=xterm-256color")
|
||||
cmd.Env = env
|
||||
winSize := &pty.Winsize{Cols: cols, Rows: rows}
|
||||
|
||||
ptmx, err := pty.StartWithSize(cmd, winSize)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pty start: %w", err)
|
||||
}
|
||||
|
||||
sess := &Session{
|
||||
PTY: ptmx,
|
||||
Cmd: cmd,
|
||||
Cols: cols,
|
||||
Rows: rows,
|
||||
}
|
||||
p.sessions[name] = sess
|
||||
|
||||
// Background goroutine: wait for process exit and clean up.
|
||||
go func() {
|
||||
cmd.Wait()
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
// Only delete if the map still holds this exact session
|
||||
// (Attach may have replaced it already).
|
||||
if cur, ok := p.sessions[name]; ok && cur == sess {
|
||||
sess.PTY.Close()
|
||||
delete(p.sessions, name)
|
||||
}
|
||||
}()
|
||||
|
||||
return sess, nil
|
||||
}
|
||||
|
||||
// Resize changes the PTY window size for a named session.
|
||||
func (p *Pool) Resize(name string, cols, rows uint16) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
sess, ok := p.sessions[name]
|
||||
if !ok {
|
||||
return fmt.Errorf("session %q not found", name)
|
||||
}
|
||||
|
||||
if err := pty.Setsize(sess.PTY, &pty.Winsize{Cols: cols, Rows: rows}); err != nil {
|
||||
return fmt.Errorf("setsize: %w", err)
|
||||
}
|
||||
sess.Cols = cols
|
||||
sess.Rows = rows
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get returns the session for name, or nil if not present.
|
||||
func (p *Pool) Get(name string) *Session {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return p.sessions[name]
|
||||
}
|
||||
|
||||
// IsAlive returns true if the named session exists and its process is alive.
|
||||
func (p *Pool) IsAlive(name string) bool {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
sess, ok := p.sessions[name]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return sess.Cmd.Process != nil && sess.Cmd.Process.Signal(syscall.Signal(0)) == nil
|
||||
}
|
||||
|
||||
// CloseAll sends SIGTERM to all sessions and closes their PTYs.
|
||||
func (p *Pool) CloseAll() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
for name, sess := range p.sessions {
|
||||
if sess.Cmd.Process != nil {
|
||||
sess.Cmd.Process.Signal(syscall.SIGTERM)
|
||||
}
|
||||
sess.PTY.Close()
|
||||
delete(p.sessions, name)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNewPool(t *testing.T) {
|
||||
p := NewPool()
|
||||
if p == nil {
|
||||
t.Fatal("NewPool returned nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPool_Get_NoSession(t *testing.T) {
|
||||
p := NewPool()
|
||||
s := p.Get("nonexistent")
|
||||
if s != nil {
|
||||
t.Fatal("Get on empty pool should return nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPool_IsAlive_NoSession(t *testing.T) {
|
||||
p := NewPool()
|
||||
if p.IsAlive("nonexistent") {
|
||||
t.Fatal("IsAlive on empty pool should return false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPool_CloseAll_Empty(t *testing.T) {
|
||||
p := NewPool()
|
||||
p.CloseAll() // must not panic
|
||||
}
|
||||
|
||||
func TestPool_Attach_Real(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
name := "test-muxterm"
|
||||
|
||||
// Ensure the tmux session exists.
|
||||
exec.Command("tmux", "kill-session", "-t", name).Run()
|
||||
cmd := exec.Command("tmux", "new-session", "-d", "-s", name, "-x", "80", "-y", "24")
|
||||
if err := cmd.Run(); err != nil {
|
||||
t.Fatalf("failed to create tmux session %q: %v", name, err)
|
||||
}
|
||||
defer exec.Command("tmux", "kill-session", "-t", name).Run()
|
||||
|
||||
p := NewPool()
|
||||
defer p.CloseAll()
|
||||
|
||||
// Attach
|
||||
sess, err := p.Attach(name, 80, 24)
|
||||
if err != nil {
|
||||
t.Fatalf("Attach failed: %v", err)
|
||||
}
|
||||
if sess == nil {
|
||||
t.Fatal("Attach returned nil session")
|
||||
}
|
||||
if sess.PTY == nil {
|
||||
t.Fatal("Session PTY is nil")
|
||||
}
|
||||
|
||||
// IsAlive
|
||||
if !p.IsAlive(name) {
|
||||
t.Fatal("IsAlive should return true after Attach")
|
||||
}
|
||||
|
||||
// Resize
|
||||
err = p.Resize(name, 120, 40)
|
||||
if err != nil {
|
||||
t.Fatalf("Resize failed: %v", err)
|
||||
}
|
||||
resized := p.Get(name)
|
||||
if resized.Cols != 120 {
|
||||
t.Errorf("After resize Cols = %d, want 120", resized.Cols)
|
||||
}
|
||||
if resized.Rows != 40 {
|
||||
t.Errorf("After resize Rows = %d, want 40", resized.Rows)
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
p.CloseAll()
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
if p.IsAlive(name) {
|
||||
t.Fatal("IsAlive should return false after CloseAll")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package main
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// --- Client-to-server message types ---
|
||||
|
||||
// AttachMsg requests attaching to a named tmux session.
|
||||
type AttachMsg struct {
|
||||
Attach string `json:"attach"`
|
||||
}
|
||||
|
||||
// ResizeMsg requests a PTY resize.
|
||||
type ResizeMsg struct {
|
||||
Resize struct {
|
||||
Cols int `json:"cols"`
|
||||
Rows int `json:"rows"`
|
||||
} `json:"resize"`
|
||||
}
|
||||
|
||||
// DetachMsg requests detaching from the current session.
|
||||
type DetachMsg struct {
|
||||
Detach bool `json:"detach"`
|
||||
}
|
||||
|
||||
// --- Server-to-client message types ---
|
||||
|
||||
// AttachedMsg confirms a successful attach.
|
||||
type AttachedMsg struct {
|
||||
Attached string `json:"attached"`
|
||||
}
|
||||
|
||||
// ErrorMsg reports an error to the client.
|
||||
type ErrorMsg struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// ExitedMsg reports that a session's process has exited.
|
||||
type ExitedMsg struct {
|
||||
Exited string `json:"exited"`
|
||||
}
|
||||
|
||||
// ParseControlMessage unmarshals a JSON control message and returns the
|
||||
// message kind and a pointer to the typed value. Returns ("invalid", nil)
|
||||
// for unparseable JSON and ("unknown", nil) for unrecognised keys.
|
||||
func ParseControlMessage(data []byte) (string, interface{}) {
|
||||
var raw map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return "invalid", nil
|
||||
}
|
||||
|
||||
if _, ok := raw["attach"]; ok {
|
||||
var msg AttachMsg
|
||||
if err := json.Unmarshal(data, &msg); err != nil {
|
||||
return "invalid", nil
|
||||
}
|
||||
return "attach", &msg
|
||||
}
|
||||
|
||||
if _, ok := raw["resize"]; ok {
|
||||
var msg ResizeMsg
|
||||
if err := json.Unmarshal(data, &msg); err != nil {
|
||||
return "invalid", nil
|
||||
}
|
||||
return "resize", &msg
|
||||
}
|
||||
|
||||
if _, ok := raw["detach"]; ok {
|
||||
var msg DetachMsg
|
||||
if err := json.Unmarshal(data, &msg); err != nil {
|
||||
return "invalid", nil
|
||||
}
|
||||
return "detach", &msg
|
||||
}
|
||||
|
||||
return "unknown", nil
|
||||
}
|
||||
|
||||
// MarshalAttached returns JSON for an AttachedMsg.
|
||||
func MarshalAttached(session string) []byte {
|
||||
b, _ := json.Marshal(AttachedMsg{Attached: session})
|
||||
return b
|
||||
}
|
||||
|
||||
// MarshalError returns JSON for an ErrorMsg.
|
||||
func MarshalError(msg string) []byte {
|
||||
b, _ := json.Marshal(ErrorMsg{Error: msg})
|
||||
return b
|
||||
}
|
||||
|
||||
// MarshalExited returns JSON for an ExitedMsg.
|
||||
func MarshalExited(session string) []byte {
|
||||
b, _ := json.Marshal(ExitedMsg{Exited: session})
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseAttachMessage(t *testing.T) {
|
||||
data := []byte(`{"attach": "dev-server"}`)
|
||||
kind, val := ParseControlMessage(data)
|
||||
if kind != "attach" {
|
||||
t.Fatalf("expected kind=attach, got %q", kind)
|
||||
}
|
||||
msg, ok := val.(*AttachMsg)
|
||||
if !ok {
|
||||
t.Fatalf("expected *AttachMsg, got %T", val)
|
||||
}
|
||||
if msg.Attach != "dev-server" {
|
||||
t.Errorf("expected Attach=%q, got %q", "dev-server", msg.Attach)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseResizeMessage(t *testing.T) {
|
||||
data := []byte(`{"resize": {"cols": 120, "rows": 40}}`)
|
||||
kind, val := ParseControlMessage(data)
|
||||
if kind != "resize" {
|
||||
t.Fatalf("expected kind=resize, got %q", kind)
|
||||
}
|
||||
msg, ok := val.(*ResizeMsg)
|
||||
if !ok {
|
||||
t.Fatalf("expected *ResizeMsg, got %T", val)
|
||||
}
|
||||
if msg.Resize.Cols != 120 {
|
||||
t.Errorf("expected Cols=120, got %d", msg.Resize.Cols)
|
||||
}
|
||||
if msg.Resize.Rows != 40 {
|
||||
t.Errorf("expected Rows=40, got %d", msg.Resize.Rows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDetachMessage(t *testing.T) {
|
||||
data := []byte(`{"detach": true}`)
|
||||
kind, val := ParseControlMessage(data)
|
||||
if kind != "detach" {
|
||||
t.Fatalf("expected kind=detach, got %q", kind)
|
||||
}
|
||||
msg, ok := val.(*DetachMsg)
|
||||
if !ok {
|
||||
t.Fatalf("expected *DetachMsg, got %T", val)
|
||||
}
|
||||
if !msg.Detach {
|
||||
t.Errorf("expected Detach=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInvalidJSON(t *testing.T) {
|
||||
data := []byte(`not json`)
|
||||
kind, val := ParseControlMessage(data)
|
||||
if kind != "invalid" {
|
||||
t.Errorf("expected kind=invalid, got %q", kind)
|
||||
}
|
||||
if val != nil {
|
||||
t.Errorf("expected nil val for invalid, got %v", val)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseUnknownMessage(t *testing.T) {
|
||||
data := []byte(`{"foo": "bar"}`)
|
||||
kind, val := ParseControlMessage(data)
|
||||
if kind != "unknown" {
|
||||
t.Errorf("expected kind=unknown, got %q", kind)
|
||||
}
|
||||
if val != nil {
|
||||
t.Errorf("expected nil val for unknown, got %v", val)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarshalAttached(t *testing.T) {
|
||||
data := MarshalAttached("dev-server")
|
||||
var msg AttachedMsg
|
||||
if err := json.Unmarshal(data, &msg); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if msg.Attached != "dev-server" {
|
||||
t.Errorf("expected Attached=%q, got %q", "dev-server", msg.Attached)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarshalError(t *testing.T) {
|
||||
data := MarshalError("something broke")
|
||||
var msg ErrorMsg
|
||||
if err := json.Unmarshal(data, &msg); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if msg.Error != "something broke" {
|
||||
t.Errorf("expected Error=%q, got %q", "something broke", msg.Error)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarshalExited(t *testing.T) {
|
||||
data := MarshalExited("dev-server")
|
||||
var msg ExitedMsg
|
||||
if err := json.Unmarshal(data, &msg); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if msg.Exited != "dev-server" {
|
||||
t.Errorf("expected Exited=%q, got %q", "dev-server", msg.Exited)
|
||||
}
|
||||
}
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// ValidateToken checks an HMAC-SHA256 signed token of the form "signature.timestamp".
|
||||
// It validates the timestamp is within ttl seconds (not too old) and within 5 seconds
|
||||
// of clock skew (not too far in the future), then verifies the HMAC signature.
|
||||
func ValidateToken(token, secret string, ttl int64) bool {
|
||||
parts := strings.SplitN(token, ".", 2)
|
||||
if len(parts) != 2 {
|
||||
return false
|
||||
}
|
||||
sig := parts[0]
|
||||
tsStr := parts[1]
|
||||
|
||||
ts, err := strconv.ParseInt(tsStr, 10, 64)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
now := time.Now().Unix()
|
||||
if now-ts > ttl {
|
||||
return false
|
||||
}
|
||||
if ts-now > 5 {
|
||||
return false
|
||||
}
|
||||
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write([]byte(tsStr))
|
||||
expected := hex.EncodeToString(mac.Sum(nil))
|
||||
|
||||
return hmac.Equal([]byte(sig), []byte(expected))
|
||||
}
|
||||
|
||||
var upgrader = websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
host := r.Host
|
||||
if strings.Contains(host, ":") {
|
||||
host = strings.Split(host, ":")[0]
|
||||
}
|
||||
return host == "localhost" || host == "127.0.0.1"
|
||||
},
|
||||
}
|
||||
|
||||
// HandleWebSocket validates the token query parameter and upgrades the connection
|
||||
// to a WebSocket, then runs the main read loop for control and binary messages.
|
||||
func HandleWebSocket(pool *Pool, secret string, w http.ResponseWriter, r *http.Request) {
|
||||
token := r.URL.Query().Get("token")
|
||||
if !ValidateToken(token, secret, 30) {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
log.Printf("ws upgrade: %v", err)
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
activeSession string
|
||||
activePTY *os.File
|
||||
ptyCancel chan struct{}
|
||||
)
|
||||
|
||||
startRelay := func(sess *Session, name string) {
|
||||
cancel := make(chan struct{})
|
||||
mu.Lock()
|
||||
ptyCancel = cancel
|
||||
mu.Unlock()
|
||||
|
||||
go func() {
|
||||
buf := make([]byte, 32*1024)
|
||||
for {
|
||||
select {
|
||||
case <-cancel:
|
||||
return
|
||||
default:
|
||||
}
|
||||
n, err := sess.PTY.Read(buf)
|
||||
if n > 0 {
|
||||
if writeErr := conn.WriteMessage(websocket.BinaryMessage, buf[:n]); writeErr != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
if err == io.EOF || strings.Contains(err.Error(), "input/output error") {
|
||||
conn.WriteMessage(websocket.TextMessage, MarshalExited(name))
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
stopRelay := func() {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if ptyCancel != nil {
|
||||
close(ptyCancel)
|
||||
ptyCancel = nil
|
||||
}
|
||||
}
|
||||
|
||||
defer stopRelay()
|
||||
|
||||
for {
|
||||
msgType, data, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
switch msgType {
|
||||
case websocket.TextMessage:
|
||||
kind, msg := ParseControlMessage(data)
|
||||
switch kind {
|
||||
case "attach":
|
||||
attach := msg.(*AttachMsg)
|
||||
stopRelay()
|
||||
|
||||
sess, err := pool.Attach(attach.Attach, 80, 24)
|
||||
if err != nil {
|
||||
conn.WriteMessage(websocket.TextMessage, MarshalError(err.Error()))
|
||||
continue
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
activeSession = attach.Attach
|
||||
activePTY = sess.PTY
|
||||
mu.Unlock()
|
||||
|
||||
sendSIGWINCH(sess)
|
||||
startRelay(sess, attach.Attach)
|
||||
conn.WriteMessage(websocket.TextMessage, MarshalAttached(attach.Attach))
|
||||
|
||||
case "resize":
|
||||
resize := msg.(*ResizeMsg)
|
||||
mu.Lock()
|
||||
name := activeSession
|
||||
mu.Unlock()
|
||||
if name != "" {
|
||||
pool.Resize(name, uint16(resize.Resize.Cols), uint16(resize.Resize.Rows))
|
||||
}
|
||||
|
||||
case "detach":
|
||||
stopRelay()
|
||||
mu.Lock()
|
||||
activeSession = ""
|
||||
activePTY = nil
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
case websocket.BinaryMessage:
|
||||
mu.Lock()
|
||||
pty := activePTY
|
||||
mu.Unlock()
|
||||
if pty != nil {
|
||||
pty.Write(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sendSIGWINCH sends SIGWINCH to the session's process to trigger tmux repaint.
|
||||
func sendSIGWINCH(s *Session) {
|
||||
if s == nil || s.Cmd == nil || s.Cmd.Process == nil {
|
||||
return
|
||||
}
|
||||
s.Cmd.Process.Signal(syscall.SIGWINCH)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// makeToken creates a valid HMAC-SHA256 token: sig.timestamp
|
||||
func makeToken(secret string, ts int64) string {
|
||||
tsStr := fmt.Sprintf("%d", ts)
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write([]byte(tsStr))
|
||||
sig := hex.EncodeToString(mac.Sum(nil))
|
||||
return sig + "." + tsStr
|
||||
}
|
||||
|
||||
func TestValidateToken_Valid(t *testing.T) {
|
||||
secret := "test-secret"
|
||||
ts := time.Now().Unix()
|
||||
token := makeToken(secret, ts)
|
||||
|
||||
if !ValidateToken(token, secret, 30) {
|
||||
t.Error("expected valid token to pass validation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateToken_Expired(t *testing.T) {
|
||||
secret := "test-secret"
|
||||
ts := time.Now().Unix() - 60 // 60 seconds ago
|
||||
token := makeToken(secret, ts)
|
||||
|
||||
if ValidateToken(token, secret, 30) {
|
||||
t.Error("expected expired token (60s ago) to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateToken_WrongSecret(t *testing.T) {
|
||||
ts := time.Now().Unix()
|
||||
token := makeToken("right-secret", ts)
|
||||
|
||||
if ValidateToken(token, "wrong-secret", 30) {
|
||||
t.Error("expected token signed with wrong secret to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateToken_MalformedNoTimestamp(t *testing.T) {
|
||||
// No dot separator — cannot split into sig.timestamp
|
||||
if ValidateToken("nodothere", "secret", 30) {
|
||||
t.Error("expected malformed token (no dot) to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateToken_EmptyToken(t *testing.T) {
|
||||
if ValidateToken("", "secret", 30) {
|
||||
t.Error("expected empty token to be rejected")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user