fix: handle HQ mode shutdown gracefully to prevent e2e test errors

- Add global shutdown state tracking via shutdown-state.ts module
- Update refresh-sessions endpoint to return 503 during shutdown
- Skip HQ notifications in control-dir-watcher during shutdown
- Disable remote health checks during server shutdown
- Suppress expected connection errors when servers are shutting down

This prevents the flood of "Failed to refresh sessions" and "Failed to
notify HQ" errors that were appearing in the HQ e2e test logs when
servers were shutting down.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Mario Zechner 2025-06-20 23:39:08 +02:00
parent f86d089226
commit b1718c27fa
7 changed files with 107 additions and 177 deletions

View file

@ -1,7 +1,6 @@
# Claude Development Notes
After receiving the first user mesage, read spec.md before you proceed. The spec.md contains a map of this code base that should help you navigate it.
**IMPORTANT**: BEFORE YOU DO ANYTHING, READ spec.md IN FULL USING THE READ TOOL!
**IMPORTANT**: NEVER USE GREP. ALWAYS USE RIPGREP!
## Updating spec.md
@ -37,6 +36,7 @@ As code changes, the spec.md might get outdated. If you detect outdated informat
- `npm run lint:fix`
- `npm run typecheck`
- Always fix all linting and type checking errors.
- Never run the tests, unless explicitely asked to. `npm run test`
## Server Execution
- NEVER RUN THE SERVER YOURSELF, I ALWAYS RUN IT ON THE SIDE VIA NPM RUN DEV!

View file

@ -1,179 +1,55 @@
# VibeTunnel Web Frontend
# VibeTunnel
A modern web interface for the VibeTunnel terminal multiplexer built with TypeScript, Lit Elements, and XTerm.js. Provides professional terminal emulation with mobile-optimized controls and real-time session management.
## Features
- **Professional Terminal Emulation** using XTerm.js with full VT compatibility
- **Real-time Session Management** with live streaming via Server-Sent Events
- **Mobile-Optimized Interface** with touch controls and responsive design
- **Session Snapshots** for previewing terminal output in card view
- **Interactive Terminal Input** with full keyboard support and mobile input overlay
- **VS Code Dark Theme** with consistent styling throughout
- **Custom Font Support** using Fira Code with programming ligatures
- **File Browser** for selecting working directories
- **Session Lifecycle Management** (create, monitor, kill, cleanup)
Web-based terminal multiplexer with distributed architecture support.
## Quick Start
1. **Install dependencies:**
```bash
npm install
```
```bash
npm install
npm run dev # Starts server + auto-rebuilds
```
2. **Start development server:**
```bash
npm run dev
```
Open http://localhost:3000
3. **Open browser:**
Navigate to `http://localhost:3000`
## Development Scripts
## Server Modes
```bash
# Development (auto-rebuild and watch)
npm run dev # Start full dev environment
npm run watch:server # Watch server TypeScript only
npm run watch:css # Watch CSS changes only
# Standalone server
npm run dev
# Building
npm run build # Build everything for production
npm run build:server # Build server TypeScript
npm run build:client # Build client TypeScript
npm run build:css # Build Tailwind CSS
# HQ server (manages remote servers)
npm run dev -- --hq
# Bundling (ES modules)
npm run bundle # Bundle client code
npm run bundle:watch # Watch and bundle client code
# Code Quality
npm run lint # Check ESLint issues
npm run lint:fix # Auto-fix ESLint issues
npm run format # Format code with Prettier
npm run format:check # Check code formatting
npm run pre-commit # Run all quality checks
# Testing
npm run test # Run Jest tests
npm run test:watch # Watch and run tests
# Remote server (connects to HQ)
npm run dev -- --hq-url http://hq-server:3000 --name remote1
```
## Build & Test
```bash
npm run build # Production build
npm run lint # Check code style
npm run typecheck # Type checking
npm test # Run tests
```
## fwd Tool
CLI that spawns PTY sessions integrated with VibeTunnel:
```bash
# Forward a command to VibeTunnel
npx tsx src/fwd.ts <command> [args...]
# Monitor-only mode (no input)
npx tsx src/fwd.ts --monitor-only <command>
```
Creates persistent sessions accessible via the web UI.
## Architecture
### Client-Side Components
Built with **Lit Elements** (Web Components):
```
src/client/
├── app.ts # Main application controller
├── components/
│ ├── app-header.ts # Main navigation and controls
│ ├── session-list.ts # Session grid with cards
│ ├── session-card.ts # Individual session preview
│ ├── session-view.ts # Full terminal view
│ ├── session-create-form.ts # New session modal
│ └── file-browser.ts # Directory selection
├── renderer.ts # XTerm.js terminal renderer
└── scale-fit-addon.ts # Custom terminal scaling
```
### Server-Side Architecture
**Express.js** server with **tty-fwd integration**:
```
src/
├── server.ts # Main Express server
└── input.css # Tailwind source styles
```
### Build Output
```
dist/ # Compiled TypeScript
public/
├── bundle/
│ ├── client-bundle.js # Bundled client code
│ ├── renderer.js # Terminal renderer
│ └── output.css # Compiled styles
├── fonts/ # Fira Code font files
└── index.html # Main HTML
```
## API Reference
### Session Management
```
GET /api/sessions # List all sessions
POST /api/sessions # Create new session
DELETE /api/sessions/:id # Kill session
DELETE /api/sessions/:id/cleanup # Clean up session files
POST /api/cleanup-exited # Clean all exited sessions
```
### Terminal I/O
```
GET /api/sessions/:id/stream # Live session stream (SSE)
GET /api/sessions/:id/snapshot # Session snapshot (cast format)
POST /api/sessions/:id/input # Send input to session
```
### File System
```
GET /api/fs/browse?path=<path> # Browse directories
POST /api/mkdir # Create directory
```
## Technology Stack
- **Frontend Framework:** Lit Elements (Web Components)
- **Terminal Emulation:** XTerm.js with custom addons
- **Styling:** Tailwind CSS with VS Code theme
- **Typography:** Fira Code Variable Font
- **Backend:** Express.js + TypeScript
- **Terminal Backend:** tty-fwd (Rust binary)
- **Build Tools:** TypeScript, ESBuild, Tailwind
- **Code Quality:** ESLint, Prettier, Pre-commit hooks
## Mobile Support
- **Touch-optimized scrolling** with proper overscroll prevention
- **Mobile input overlay** with virtual keyboard support
- **Responsive design** with mobile-first approach
- **Gesture navigation** (swipe from edge to go back)
- **Pull-to-refresh prevention** during terminal interaction
## Browser Compatibility
- **Modern ES6+ browsers** (Chrome 63+, Firefox 67+, Safari 13+)
- **Mobile browsers** with full touch support
- **Progressive enhancement** with graceful degradation
## Deployment
1. **Build for production:**
```bash
npm run build
```
2. **Start production server:**
```bash
npm start
```
3. **Environment variables:**
- `PORT` - Server port (default: 3000)
- `TTY_FWD_CONTROL_DIR` - tty-fwd control directory
## Development Notes
- **Hot reload** enabled in development
- **TypeScript strict mode** with comprehensive type checking
- **ESLint + Prettier** enforced via pre-commit hooks
- **Component-based architecture** for maintainability
- **Mobile-first responsive design** principles
- **Server**: Express + node-pty for terminal sessions
- **Client**: Lit web components + xterm.js
- **Streaming**: SSE for output, WebSocket for binary buffers
- **Protocol**: Binary-optimized terminal state synchronization

View file

@ -1,5 +1,6 @@
import chalk from 'chalk';
import { createApp } from './app.js';
import { setShuttingDown } from './services/shutdown-state.js';
// Create and configure the app
const appInstance = createApp();
@ -31,6 +32,7 @@ if (isMainModule) {
}
isShuttingDown = true;
setShuttingDown(true);
console.log(chalk.yellow('\nShutting down...'));
try {

View file

@ -1,6 +1,7 @@
import { Router } from 'express';
import { RemoteRegistry } from '../services/remote-registry.js';
import chalk from 'chalk';
import { isShuttingDown } from '../services/shutdown-state.js';
interface RemoteRoutesConfig {
remoteRegistry: RemoteRegistry | null;
@ -74,6 +75,11 @@ export function createRemoteRoutes(config: RemoteRoutesConfig): Router {
return res.status(404).json({ error: 'Not running in HQ mode' });
}
// If server is shutting down, return service unavailable
if (isShuttingDown()) {
return res.status(503).json({ error: 'Server is shutting down' });
}
const remoteName = req.params.remoteName;
const { action, sessionId } = req.body;
@ -109,6 +115,14 @@ export function createRemoteRoutes(config: RemoteRoutesConfig): Router {
throw new Error(`Failed to fetch sessions: ${response.status}`);
}
} catch (error) {
// During shutdown, connection failures are expected
if (isShuttingDown()) {
console.log(
chalk.yellow(`Remote ${remote.name} refresh failed during shutdown (expected)`)
);
return res.status(503).json({ error: 'Server is shutting down' });
}
console.error(chalk.red(`Failed to refresh sessions for remote ${remote.name}:`), error);
res.status(500).json({ error: 'Failed to refresh sessions' });
}

View file

@ -3,6 +3,7 @@ import * as path from 'path';
import chalk from 'chalk';
import { RemoteRegistry } from './remote-registry.js';
import { HQClient } from './hq-client.js';
import { isShuttingDown } from './shutdown-state.js';
interface ControlDirWatcherConfig {
controlDir: string;
@ -57,7 +58,7 @@ export class ControlDirWatcher {
console.log(chalk.blue(`Detected new external session: ${sessionId}`));
// If we're a remote server registered with HQ, immediately notify HQ
if (this.config.hqClient) {
if (this.config.hqClient && !isShuttingDown()) {
try {
await this.notifyHQAboutSession(sessionId, 'created');
} catch (error) {
@ -73,14 +74,17 @@ export class ControlDirWatcher {
console.log(chalk.yellow(`Detected removed external session: ${sessionId}`));
// If we're a remote server registered with HQ, immediately notify HQ
if (this.config.hqClient) {
if (this.config.hqClient && !isShuttingDown()) {
try {
await this.notifyHQAboutSession(sessionId, 'deleted');
} catch (error) {
console.error(
chalk.red(`Failed to notify HQ about deleted session ${sessionId}:`),
error
);
// During shutdown, this is expected
if (!isShuttingDown()) {
console.error(
chalk.red(`Failed to notify HQ about deleted session ${sessionId}:`),
error
);
}
}
}
@ -98,7 +102,7 @@ export class ControlDirWatcher {
sessionId: string,
action: 'created' | 'deleted'
): Promise<void> {
if (!this.config.hqClient) return;
if (!this.config.hqClient || isShuttingDown()) return;
const hqUrl = this.config.hqClient.getHQUrl();
const hqAuth = this.config.hqClient.getHQAuth();
@ -120,6 +124,10 @@ export class ControlDirWatcher {
});
if (!response.ok) {
// If we get a 503 during shutdown, that's expected
if (response.status === 503 && isShuttingDown()) {
return;
}
throw new Error(`HQ responded with ${response.status}`);
}

View file

@ -1,3 +1,5 @@
import { isShuttingDown } from './shutdown-state.js';
export interface RemoteServer {
id: string;
name: string;
@ -116,6 +118,11 @@ export class RemoteRegistry {
}
private async checkRemoteHealth(remote: RemoteServer): Promise<void> {
// Skip health checks during shutdown
if (isShuttingDown()) {
return;
}
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.HEALTH_CHECK_TIMEOUT);
@ -139,14 +146,22 @@ export class RemoteRegistry {
throw new Error(`HTTP ${response.status}`);
}
} catch (error) {
console.log(`Remote failed health check: ${remote.name} (${remote.id}) - ${error}`);
// Remove the remote if it fails health check
this.unregister(remote.id);
// During shutdown, don't log errors or unregister remotes
if (!isShuttingDown()) {
console.log(`Remote failed health check: ${remote.name} (${remote.id}) - ${error}`);
// Remove the remote if it fails health check
this.unregister(remote.id);
}
}
}
private startHealthChecker() {
this.healthCheckInterval = setInterval(() => {
// Skip health checks during shutdown
if (isShuttingDown()) {
return;
}
// Check all remotes in parallel
const healthChecks = Array.from(this.remotes.values()).map((remote) =>
this.checkRemoteHealth(remote)

View file

@ -0,0 +1,15 @@
/**
* Global shutdown state management for the server.
* This module tracks whether the server is currently shutting down
* to allow various components to handle shutdown gracefully.
*/
let shuttingDown = false;
export function isShuttingDown(): boolean {
return shuttingDown;
}
export function setShuttingDown(value: boolean): void {
shuttingDown = value;
}