Finalize Go-only migration, runtime hardening, and CI/container optimization
This commit consolidates the full repository transition to a Go-first codebase and captures the follow-up performance/reliability work completed in the same stream. Highlights: - Remove Python implementation and test suites (, , , ) and retire Python-specific docs/instructions. - Move and standardize static web assets under , updating Bun/TypeScript build paths and server static resolution logic. - Rewrite developer workflow to Makefile-first Go targets (vet/test/race/coverage/fuzz/build) and align repository guidance/docs accordingly. - Update Docker and CI/CD for leaner artifacts: - switch to Alpine-based multi-stage build with stripped Go binary - install only minimal runtime deps (, ) - tighten Docker build context via - ensure workflows build/publish the target. - Improve runtime correctness/latency and reduce duplication: - explicit WebSocket outbound frame typing (text vs binary) instead of payload-byte heuristics - SSE activity fan-out outside global lock and safer subscriber lifecycle - shared session output/snapshot helpers to reduce duplicated logic - restart-safe channel lifecycle for Docker watcher/stats start-stop-start flows - faster screenshot cold-start path (poll-until-ready within timeout vs fixed sleep). - Add/expand regression coverage for the above lifecycle and helper paths. Validation run: - bun run build [32mBundled 3 modules in 10ms[0m [34mterminal.js[33m 0.68 MB [2m(entry point)[0m (Bun typecheck + bundle) - cd go && go vet ./... cd go && go test ./... ok github.com/rcarmo/webterm-go-port/cmd/webterm (cached) ok github.com/rcarmo/webterm-go-port/internal/terminalstate (cached) ok github.com/rcarmo/webterm-go-port/webterm (cached) cd go && go test ./webterm -coverprofile=coverage.out && go tool cover -func=coverage.out ok github.com/rcarmo/webterm-go-port/webterm (cached) coverage: 81.0% of statements github.com/rcarmo/webterm-go-port/webterm/cli.go:14: RunCLI 51.6% github.com/rcarmo/webterm-go-port/webterm/config.go:25: DefaultConfig 100.0% github.com/rcarmo/webterm-go-port/webterm/config.go:29: LoadLandingYAML 82.6% github.com/rcarmo/webterm-go-port/webterm/config.go:70: LoadComposeManifest 76.9% github.com/rcarmo/webterm-go-port/webterm/config.go:114: extractLabel 92.3% github.com/rcarmo/webterm-go-port/webterm/config.go:138: asString 80.0% github.com/rcarmo/webterm-go-port/webterm/constants.go:27: EnvBool 50.0% github.com/rcarmo/webterm-go-port/webterm/docker_exec_session.go:30: Read 100.0% github.com/rcarmo/webterm-go-port/webterm/docker_exec_session.go:56: NewDockerExecSession 100.0% github.com/rcarmo/webterm-go-port/webterm/docker_exec_session.go:72: Open 90.0% github.com/rcarmo/webterm-go-port/webterm/docker_exec_session.go:99: Start 85.7% github.com/rcarmo/webterm-go-port/webterm/docker_exec_session.go:119: readLoop 83.3% github.com/rcarmo/webterm-go-port/webterm/docker_exec_session.go:143: handleOutput 100.0% github.com/rcarmo/webterm-go-port/webterm/docker_exec_session.go:153: createExec 75.0% github.com/rcarmo/webterm-go-port/webterm/docker_exec_session.go:183: startExecSocket 60.7% github.com/rcarmo/webterm-go-port/webterm/docker_exec_session.go:219: resizeExec 83.3% github.com/rcarmo/webterm-go-port/webterm/docker_exec_session.go:237: Close 90.0% github.com/rcarmo/webterm-go-port/webterm/docker_exec_session.go:250: Wait 100.0% github.com/rcarmo/webterm-go-port/webterm/docker_exec_session.go:257: SetTerminalSize 81.8% github.com/rcarmo/webterm-go-port/webterm/docker_exec_session.go:274: ForceRedraw 100.0% github.com/rcarmo/webterm-go-port/webterm/docker_exec_session.go:281: SendBytes 100.0% github.com/rcarmo/webterm-go-port/webterm/docker_exec_session.go:294: SendMeta 100.0% github.com/rcarmo/webterm-go-port/webterm/docker_exec_session.go:298: IsRunning 100.0% github.com/rcarmo/webterm-go-port/webterm/docker_exec_session.go:304: GetReplayBuffer 100.0% github.com/rcarmo/webterm-go-port/webterm/docker_exec_session.go:308: GetScreenSnapshot 100.0% github.com/rcarmo/webterm-go-port/webterm/docker_exec_session.go:316: UpdateConnector 80.0% github.com/rcarmo/webterm-go-port/webterm/docker_http.go:23: DockerSocketPath 100.0% github.com/rcarmo/webterm-go-port/webterm/docker_http.go:37: newUnixHTTPClient 100.0% github.com/rcarmo/webterm-go-port/webterm/docker_http.go:47: sharedUnixClient 91.7% github.com/rcarmo/webterm-go-port/webterm/docker_http.go:64: unixJSONRequest 84.2% github.com/rcarmo/webterm-go-port/webterm/docker_stats.go:33: NewDockerStatsCollector 100.0% github.com/rcarmo/webterm-go-port/webterm/docker_stats.go:47: Available 72.7% github.com/rcarmo/webterm-go-port/webterm/docker_stats.go:64: Start 80.0% github.com/rcarmo/webterm-go-port/webterm/docker_stats.go:78: Stop 75.0% github.com/rcarmo/webterm-go-port/webterm/docker_stats.go:90: AddService 100.0% github.com/rcarmo/webterm-go-port/webterm/docker_stats.go:101: RemoveService 100.0% github.com/rcarmo/webterm-go-port/webterm/docker_stats.go:115: GetCPUHistory 100.0% github.com/rcarmo/webterm-go-port/webterm/docker_stats.go:124: pollLoop 95.2% github.com/rcarmo/webterm-go-port/webterm/docker_stats.go:160: discoverContainers 65.4% github.com/rcarmo/webterm-go-port/webterm/docker_stats.go:198: pollContainer 77.8% github.com/rcarmo/webterm-go-port/webterm/docker_stats.go:223: calculateCPUPercent 69.2% github.com/rcarmo/webterm-go-port/webterm/docker_stats.go:260: RenderSparklineSVG 89.3% github.com/rcarmo/webterm-go-port/webterm/docker_stats.go:299: max 100.0% github.com/rcarmo/webterm-go-port/webterm/docker_stats.go:306: toAnyMap 100.0% github.com/rcarmo/webterm-go-port/webterm/docker_stats.go:323: toStringMap 100.0% github.com/rcarmo/webterm-go-port/webterm/docker_stats.go:331: toAnySlice 100.0% github.com/rcarmo/webterm-go-port/webterm/docker_stats.go:340: toStringSlice 100.0% github.com/rcarmo/webterm-go-port/webterm/docker_stats.go:351: toUint 91.7% github.com/rcarmo/webterm-go-port/webterm/docker_stats.go:376: toInt 85.7% github.com/rcarmo/webterm-go-port/webterm/docker_watcher.go:34: NewDockerWatcher 100.0% github.com/rcarmo/webterm-go-port/webterm/docker_watcher.go:54: hasWebtermLabel 100.0% github.com/rcarmo/webterm-go-port/webterm/docker_watcher.go:60: isAutoLabel 100.0% github.com/rcarmo/webterm-go-port/webterm/docker_watcher.go:67: getContainerCommand 80.0% github.com/rcarmo/webterm-go-port/webterm/docker_watcher.go:76: getContainerTheme 100.0% github.com/rcarmo/webterm-go-port/webterm/docker_watcher.go:81: getContainerName 42.9% github.com/rcarmo/webterm-go-port/webterm/docker_watcher.go:93: containerToSlug 100.0% github.com/rcarmo/webterm-go-port/webterm/docker_watcher.go:98: addContainer 100.0% github.com/rcarmo/webterm-go-port/webterm/docker_watcher.go:119: removeContainer 100.0% github.com/rcarmo/webterm-go-port/webterm/docker_watcher.go:143: listLabeledContainers 94.1% github.com/rcarmo/webterm-go-port/webterm/docker_watcher.go:168: handleEvent 80.0% github.com/rcarmo/webterm-go-port/webterm/docker_watcher.go:202: watchEvents 85.7% github.com/rcarmo/webterm-go-port/webterm/docker_watcher.go:239: ScanExisting 60.0% github.com/rcarmo/webterm-go-port/webterm/docker_watcher.go:249: Start 83.3% github.com/rcarmo/webterm-go-port/webterm/docker_watcher.go:265: Stop 81.8% github.com/rcarmo/webterm-go-port/webterm/identity.go:12: GenerateID 94.1% github.com/rcarmo/webterm-go-port/webterm/normalize.go:13: FilterDASequences 83.3% github.com/rcarmo/webterm-go-port/webterm/replay.go:14: NewReplayBuffer 66.7% github.com/rcarmo/webterm-go-port/webterm/replay.go:21: Add 100.0% github.com/rcarmo/webterm-go-port/webterm/replay.go:43: Bytes 100.0% github.com/rcarmo/webterm-go-port/webterm/server.go:95: OnData 100.0% github.com/rcarmo/webterm-go-port/webterm/server.go💯 OnBinary 100.0% github.com/rcarmo/webterm-go-port/webterm/server.go:105: OnMeta 0.0% github.com/rcarmo/webterm-go-port/webterm/server.go:107: OnClose 100.0% github.com/rcarmo/webterm-go-port/webterm/server.go:112: NewLocalServer 100.0% github.com/rcarmo/webterm-go-port/webterm/server.go:163: findStaticPath 62.5% github.com/rcarmo/webterm-go-port/webterm/server.go:184: markRouteActivity 100.0% github.com/rcarmo/webterm-go-port/webterm/server.go:207: enqueueWSFrame 77.8% github.com/rcarmo/webterm-go-port/webterm/server.go:233: stopWSClient 100.0% github.com/rcarmo/webterm-go-port/webterm/server.go:246: wsSender 80.0% github.com/rcarmo/webterm-go-port/webterm/server.go:256: createTerminalSession 66.7% github.com/rcarmo/webterm-go-port/webterm/server.go:278: clampInt 60.0% github.com/rcarmo/webterm-go-port/webterm/server.go:288: parseResizePayload 88.9% github.com/rcarmo/webterm-go-port/webterm/server.go:303: handleWebSocket 81.1% github.com/rcarmo/webterm-go-port/webterm/server.go:425: chooseRouteForScreenshot 50.0% github.com/rcarmo/webterm-go-port/webterm/server.go:440: screenshotTTL 66.7% github.com/rcarmo/webterm-go-port/webterm/server.go:457: handleScreenshot 55.7% github.com/rcarmo/webterm-go-port/webterm/server.go:541: handleCPUSparkline 94.4% github.com/rcarmo/webterm-go-port/webterm/server.go:566: handleEvents 76.0% github.com/rcarmo/webterm-go-port/webterm/server.go:602: toIntFromQuery 100.0% github.com/rcarmo/webterm-go-port/webterm/server.go:609: dashboardTiles 81.8% github.com/rcarmo/webterm-go-port/webterm/server.go:631: handleTiles 100.0% github.com/rcarmo/webterm-go-port/webterm/server.go:636: getWSURL 65.2% github.com/rcarmo/webterm-go-port/webterm/server.go:671: handleRoot 56.8% github.com/rcarmo/webterm-go-port/webterm/server.go:732: htmlEscape 100.0% github.com/rcarmo/webterm-go-port/webterm/server.go:736: htmlAttrEscape 100.0% github.com/rcarmo/webterm-go-port/webterm/server.go:740: handleHealth 100.0% github.com/rcarmo/webterm-go-port/webterm/server.go:744: setupDockerFeatures 40.0% github.com/rcarmo/webterm-go-port/webterm/server.go:791: shutdown 62.5% github.com/rcarmo/webterm-go-port/webterm/server.go:814: Handler 100.0% github.com/rcarmo/webterm-go-port/webterm/server.go:829: Run 77.8% github.com/rcarmo/webterm-go-port/webterm/session.go:31: OnData 0.0% github.com/rcarmo/webterm-go-port/webterm/session.go:32: OnBinary 0.0% github.com/rcarmo/webterm-go-port/webterm/session.go:33: OnMeta 0.0% github.com/rcarmo/webterm-go-port/webterm/session.go:34: OnClose 0.0% github.com/rcarmo/webterm-go-port/webterm/session.go:36: dispatchSessionOutput 100.0% github.com/rcarmo/webterm-go-port/webterm/session.go:47: snapshotFromTracker 100.0% github.com/rcarmo/webterm-go-port/webterm/session_manager.go:20: NewSessionManager 100.0% github.com/rcarmo/webterm-go-port/webterm/session_manager.go:34: SetSessionFactory 100.0% github.com/rcarmo/webterm-go-port/webterm/session_manager.go:40: defaultSessionFactory 87.5% github.com/rcarmo/webterm-go-port/webterm/session_manager.go:57: splitCommand 75.0% github.com/rcarmo/webterm-go-port/webterm/session_manager.go:65: shlexSplit 100.0% github.com/rcarmo/webterm-go-port/webterm/session_manager.go:69: AddApp 87.5% github.com/rcarmo/webterm-go-port/webterm/session_manager.go:88: RemoveApp 87.5% github.com/rcarmo/webterm-go-port/webterm/session_manager.go:101: Apps 100.0% github.com/rcarmo/webterm-go-port/webterm/session_manager.go:107: AppBySlug 100.0% github.com/rcarmo/webterm-go-port/webterm/session_manager.go:114: GetDefaultApp 80.0% github.com/rcarmo/webterm-go-port/webterm/session_manager.go:123: NewSession 50.0% github.com/rcarmo/webterm-go-port/webterm/session_manager.go:167: OnSessionEnd 100.0% github.com/rcarmo/webterm-go-port/webterm/session_manager.go:176: CloseAll 100.0% github.com/rcarmo/webterm-go-port/webterm/session_manager.go:189: CloseSession 87.5% github.com/rcarmo/webterm-go-port/webterm/session_manager.go:201: GetSession 100.0% github.com/rcarmo/webterm-go-port/webterm/session_manager.go:207: GetSessionByRouteKey 100.0% github.com/rcarmo/webterm-go-port/webterm/session_manager.go:217: GetSessionIDByRouteKey 100.0% github.com/rcarmo/webterm-go-port/webterm/session_manager.go:223: GetFirstRunningSession 85.7% github.com/rcarmo/webterm-go-port/webterm/shellsplit.go:5: shlexSplitImpl 100.0% github.com/rcarmo/webterm-go-port/webterm/slugify.go:13: Slugify 100.0% github.com/rcarmo/webterm-go-port/webterm/svg_exporter.go:35: RenderTerminalSVG 92.6% github.com/rcarmo/webterm-go-port/webterm/svg_exporter.go:113: colorToHex 87.5% github.com/rcarmo/webterm-go-port/webterm/svg_exporter.go:139: isHex 100.0% github.com/rcarmo/webterm-go-port/webterm/terminal_session.go:37: NewTerminalSession 100.0% github.com/rcarmo/webterm-go-port/webterm/terminal_session.go:49: Open 86.7% github.com/rcarmo/webterm-go-port/webterm/terminal_session.go:90: Start 85.7% github.com/rcarmo/webterm-go-port/webterm/terminal_session.go:110: readLoop 100.0% github.com/rcarmo/webterm-go-port/webterm/terminal_session.go:132: handleOutput 100.0% github.com/rcarmo/webterm-go-port/webterm/terminal_session.go:142: Close 100.0% github.com/rcarmo/webterm-go-port/webterm/terminal_session.go:160: Wait 100.0% github.com/rcarmo/webterm-go-port/webterm/terminal_session.go:167: SetTerminalSize 80.0% github.com/rcarmo/webterm-go-port/webterm/terminal_session.go:190: ForceRedraw 100.0% github.com/rcarmo/webterm-go-port/webterm/terminal_session.go:198: SendBytes 88.9% github.com/rcarmo/webterm-go-port/webterm/terminal_session.go:211: SendMeta 100.0% github.com/rcarmo/webterm-go-port/webterm/terminal_session.go:215: IsRunning 100.0% github.com/rcarmo/webterm-go-port/webterm/terminal_session.go:221: GetReplayBuffer 100.0% github.com/rcarmo/webterm-go-port/webterm/terminal_session.go:225: GetScreenSnapshot 100.0% github.com/rcarmo/webterm-go-port/webterm/terminal_session.go:233: UpdateConnector 80.0% github.com/rcarmo/webterm-go-port/webterm/twoway.go:14: NewTwoWayMap 100.0% github.com/rcarmo/webterm-go-port/webterm/twoway.go:21: Set 88.9% github.com/rcarmo/webterm-go-port/webterm/twoway.go:36: DeleteKey 100.0% github.com/rcarmo/webterm-go-port/webterm/twoway.go:45: Get 100.0% github.com/rcarmo/webterm-go-port/webterm/twoway.go:52: GetKey 100.0% github.com/rcarmo/webterm-go-port/webterm/twoway.go:59: Keys 100.0% github.com/rcarmo/webterm-go-port/webterm/twoway.go:70: UnsafeForward 100.0% total: (statements) 81.0% - cd go && go test -race ./... ok github.com/rcarmo/webterm-go-port/cmd/webterm (cached) ok github.com/rcarmo/webterm-go-port/internal/terminalstate (cached) ok github.com/rcarmo/webterm-go-port/webterm (cached) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -147,14 +147,7 @@ func (s *DockerExecSession) handleOutput(data []byte) {
|
||||
tracker := s.tracker
|
||||
connector := s.connector
|
||||
s.mu.Unlock()
|
||||
if len(filtered) == 0 {
|
||||
return
|
||||
}
|
||||
s.replay.Add(filtered)
|
||||
if tracker != nil {
|
||||
_ = tracker.Feed(filtered)
|
||||
}
|
||||
connector.OnData(filtered)
|
||||
dispatchSessionOutput(filtered, tracker, s.replay, connector)
|
||||
}
|
||||
|
||||
func (s *DockerExecSession) createExec() (string, error) {
|
||||
@@ -317,14 +310,7 @@ func (s *DockerExecSession) GetScreenSnapshot() terminalstate.Snapshot {
|
||||
tracker := s.tracker
|
||||
width, height := s.width, s.height
|
||||
s.mu.RUnlock()
|
||||
if tracker == nil {
|
||||
return terminalstate.Snapshot{
|
||||
Width: width,
|
||||
Height: height,
|
||||
Buffer: make([][]terminalstate.Cell, height),
|
||||
}
|
||||
}
|
||||
return tracker.Snapshot()
|
||||
return snapshotFromTracker(tracker, width, height)
|
||||
}
|
||||
|
||||
func (s *DockerExecSession) UpdateConnector(connector SessionConnector) {
|
||||
|
||||
@@ -67,6 +67,8 @@ func (d *DockerStatsCollector) Start(serviceNames []string) {
|
||||
d.mu.Unlock()
|
||||
return
|
||||
}
|
||||
d.stopCh = make(chan struct{})
|
||||
d.doneCh = make(chan struct{})
|
||||
d.serviceList = append([]string{}, serviceNames...)
|
||||
d.running = true
|
||||
d.mu.Unlock()
|
||||
|
||||
@@ -93,3 +93,11 @@ func TestDockerStatsHelperConversions(t *testing.T) {
|
||||
t.Fatalf("max mismatch: %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDockerStatsCollectorCanRestart(t *testing.T) {
|
||||
collector := NewDockerStatsCollector("/tmp/does-not-exist.sock", "")
|
||||
collector.Start([]string{"svc"})
|
||||
collector.Stop()
|
||||
collector.Start([]string{"svc"})
|
||||
collector.Stop()
|
||||
}
|
||||
|
||||
@@ -199,8 +199,8 @@ func (w *DockerWatcher) handleEvent(event map[string]any) {
|
||||
}
|
||||
}
|
||||
|
||||
func (w *DockerWatcher) watchEvents(ctx context.Context) {
|
||||
defer close(w.waitDone)
|
||||
func (w *DockerWatcher) watchEvents(ctx context.Context, waitDone chan struct{}) {
|
||||
defer close(waitDone)
|
||||
filters := url.QueryEscape(`{"event":["start","die"],"type":["container"]}`)
|
||||
requestURL := "http://unix/events?filters=" + filters
|
||||
for {
|
||||
@@ -252,12 +252,14 @@ func (w *DockerWatcher) Start() {
|
||||
w.mu.Unlock()
|
||||
return
|
||||
}
|
||||
waitDone := make(chan struct{})
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
w.cancel = cancel
|
||||
w.waitDone = waitDone
|
||||
w.running = true
|
||||
w.mu.Unlock()
|
||||
w.ScanExisting()
|
||||
go w.watchEvents(ctx)
|
||||
go w.watchEvents(ctx, waitDone)
|
||||
}
|
||||
|
||||
func (w *DockerWatcher) Stop() {
|
||||
@@ -268,9 +270,10 @@ func (w *DockerWatcher) Stop() {
|
||||
}
|
||||
w.running = false
|
||||
cancel := w.cancel
|
||||
waitDone := w.waitDone
|
||||
w.mu.Unlock()
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
<-w.waitDone
|
||||
<-waitDone
|
||||
}
|
||||
|
||||
@@ -21,3 +21,11 @@ func TestDockerWatcherCommandAndThemeParsing(t *testing.T) {
|
||||
t.Fatalf("unexpected slug: %q", slug)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDockerWatcherCanRestart(t *testing.T) {
|
||||
watcher := NewDockerWatcher(NewSessionManager(nil), "/tmp/does-not-exist.sock", nil, nil)
|
||||
watcher.Start()
|
||||
watcher.Stop()
|
||||
watcher.Start()
|
||||
watcher.Stop()
|
||||
}
|
||||
|
||||
+55
-32
@@ -48,11 +48,16 @@ type screenshotCacheEntry struct {
|
||||
type wsClient struct {
|
||||
routeKey string
|
||||
conn *websocket.Conn
|
||||
send chan []byte
|
||||
send chan wsOutbound
|
||||
done chan struct{}
|
||||
closed atomic.Bool
|
||||
}
|
||||
|
||||
type wsOutbound struct {
|
||||
messageType int
|
||||
payload []byte
|
||||
}
|
||||
|
||||
type LocalServer struct {
|
||||
host string
|
||||
port int
|
||||
@@ -89,12 +94,12 @@ type localClientConnector struct {
|
||||
|
||||
func (c *localClientConnector) OnData(data []byte) {
|
||||
c.server.markRouteActivity(c.routeKey)
|
||||
c.server.enqueueWSData(c.routeKey, data)
|
||||
c.server.enqueueWSFrame(c.routeKey, websocket.BinaryMessage, data)
|
||||
}
|
||||
|
||||
func (c *localClientConnector) OnBinary(payload []byte) {
|
||||
c.server.markRouteActivity(c.routeKey)
|
||||
c.server.enqueueWSData(c.routeKey, payload)
|
||||
c.server.enqueueWSFrame(c.routeKey, websocket.BinaryMessage, payload)
|
||||
}
|
||||
|
||||
func (c *localClientConnector) OnMeta(_ map[string]any) {}
|
||||
@@ -162,9 +167,11 @@ func findStaticPath() string {
|
||||
}
|
||||
}
|
||||
candidates := []string{
|
||||
filepath.Join(".", "src", "webterm", "static"),
|
||||
filepath.Join("..", "src", "webterm", "static"),
|
||||
filepath.Join("..", "..", "src", "webterm", "static"),
|
||||
filepath.Join(".", "webterm", "static"),
|
||||
filepath.Join(".", "go", "webterm", "static"),
|
||||
filepath.Join("..", "webterm", "static"),
|
||||
filepath.Join("..", "go", "webterm", "static"),
|
||||
filepath.Join("..", "..", "webterm", "static"),
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if stat, err := os.Stat(candidate); err == nil && stat.IsDir() {
|
||||
@@ -179,28 +186,37 @@ func (s *LocalServer) markRouteActivity(routeKey string) {
|
||||
s.mu.Lock()
|
||||
s.routeLastActivity[routeKey] = now
|
||||
last := s.routeLastSSE[routeKey]
|
||||
if now.Sub(last) >= 250*time.Millisecond {
|
||||
s.routeLastSSE[routeKey] = now
|
||||
for subscriber := range s.sseSubscribers {
|
||||
select {
|
||||
case subscriber <- routeKey:
|
||||
default:
|
||||
}
|
||||
}
|
||||
if now.Sub(last) < 250*time.Millisecond {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
s.routeLastSSE[routeKey] = now
|
||||
subscribers := make([]chan string, 0, len(s.sseSubscribers))
|
||||
for subscriber := range s.sseSubscribers {
|
||||
subscribers = append(subscribers, subscriber)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
for _, subscriber := range subscribers {
|
||||
select {
|
||||
case subscriber <- routeKey:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *LocalServer) enqueueWSData(routeKey string, data []byte) {
|
||||
func (s *LocalServer) enqueueWSFrame(routeKey string, messageType int, data []byte) {
|
||||
s.mu.RLock()
|
||||
client := s.wsClients[routeKey]
|
||||
s.mu.RUnlock()
|
||||
if client == nil || client.closed.Load() {
|
||||
return
|
||||
}
|
||||
payload := append([]byte{}, data...)
|
||||
frame := wsOutbound{
|
||||
messageType: messageType,
|
||||
payload: append([]byte{}, data...),
|
||||
}
|
||||
select {
|
||||
case client.send <- payload:
|
||||
case client.send <- frame:
|
||||
default:
|
||||
// Drop oldest, try again
|
||||
select {
|
||||
@@ -208,7 +224,7 @@ func (s *LocalServer) enqueueWSData(routeKey string, data []byte) {
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case client.send <- payload:
|
||||
case client.send <- frame:
|
||||
default:
|
||||
}
|
||||
}
|
||||
@@ -229,14 +245,9 @@ func (s *LocalServer) stopWSClient(routeKey string) {
|
||||
|
||||
func (s *LocalServer) wsSender(client *wsClient) {
|
||||
defer close(client.done)
|
||||
for payload := range client.send {
|
||||
for outbound := range client.send {
|
||||
_ = client.conn.SetWriteDeadline(time.Now().Add(wsSendTimeout))
|
||||
// Detect JSON messages (start with '[') vs binary terminal data
|
||||
msgType := websocket.BinaryMessage
|
||||
if len(payload) > 0 && payload[0] == '[' {
|
||||
msgType = websocket.TextMessage
|
||||
}
|
||||
if err := client.conn.WriteMessage(msgType, payload); err != nil {
|
||||
if err := client.conn.WriteMessage(outbound.messageType, outbound.payload); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -304,7 +315,7 @@ func (s *LocalServer) handleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
client := &wsClient{
|
||||
routeKey: routeKey,
|
||||
conn: conn,
|
||||
send: make(chan []byte, wsSendQueueMax),
|
||||
send: make(chan wsOutbound, wsSendQueueMax),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
s.mu.Lock()
|
||||
@@ -319,8 +330,12 @@ func (s *LocalServer) handleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
if err != nil || client.closed.Load() {
|
||||
return
|
||||
}
|
||||
frame := wsOutbound{
|
||||
messageType: websocket.TextMessage,
|
||||
payload: data,
|
||||
}
|
||||
select {
|
||||
case client.send <- data:
|
||||
case client.send <- frame:
|
||||
default:
|
||||
}
|
||||
}
|
||||
@@ -333,7 +348,7 @@ func (s *LocalServer) handleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
sessionCreated = true
|
||||
replay := daResponsePattern.ReplaceAll(session.GetReplayBuffer(), nil)
|
||||
if len(replay) > 0 {
|
||||
s.enqueueWSData(routeKey, replay)
|
||||
s.enqueueWSFrame(routeKey, websocket.BinaryMessage, replay)
|
||||
}
|
||||
} else {
|
||||
s.sessionManager.OnSessionEnd(sessionID)
|
||||
@@ -445,9 +460,18 @@ func (s *LocalServer) handleScreenshot(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok && routeKey != "" {
|
||||
if _, exists := s.sessionManager.AppBySlug(routeKey); exists {
|
||||
_ = s.createTerminalSession(routeKey, DefaultTerminalWidth, DefaultTerminalHeight)
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
session = s.sessionManager.GetSessionByRouteKey(routeKey)
|
||||
ok = session != nil
|
||||
deadline := time.Now().Add(500 * time.Millisecond)
|
||||
for {
|
||||
session = s.sessionManager.GetSessionByRouteKey(routeKey)
|
||||
if session != nil {
|
||||
ok = true
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
break
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !ok || session == nil {
|
||||
@@ -555,7 +579,6 @@ func (s *LocalServer) handleEvents(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
s.mu.Lock()
|
||||
delete(s.sseSubscribers, channel)
|
||||
close(channel)
|
||||
s.mu.Unlock()
|
||||
}()
|
||||
|
||||
|
||||
@@ -214,3 +214,31 @@ func TestRootTerminalPageAndSparklineValidation(t *testing.T) {
|
||||
t.Fatalf("expected 400 for missing container, got %d", resp2.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkRouteActivityBroadcastsWithoutBlockingGlobalLock(t *testing.T) {
|
||||
server := NewLocalServer(Config{}, ServerOptions{})
|
||||
ready := make(chan string, 1)
|
||||
full := make(chan string, 1)
|
||||
full <- "occupied"
|
||||
|
||||
server.mu.Lock()
|
||||
server.sseSubscribers[ready] = struct{}{}
|
||||
server.sseSubscribers[full] = struct{}{}
|
||||
server.routeLastSSE["route-a"] = time.Now().Add(-time.Second)
|
||||
server.mu.Unlock()
|
||||
|
||||
start := time.Now()
|
||||
server.markRouteActivity("route-a")
|
||||
if elapsed := time.Since(start); elapsed > 100*time.Millisecond {
|
||||
t.Fatalf("markRouteActivity took too long: %s", elapsed)
|
||||
}
|
||||
|
||||
select {
|
||||
case got := <-ready:
|
||||
if got != "route-a" {
|
||||
t.Fatalf("unexpected broadcast payload: %q", got)
|
||||
}
|
||||
default:
|
||||
t.Fatalf("expected route activity broadcast")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,3 +32,28 @@ func (noopConnector) OnData([]byte) {}
|
||||
func (noopConnector) OnBinary([]byte) {}
|
||||
func (noopConnector) OnMeta(map[string]any) {}
|
||||
func (noopConnector) OnClose() {}
|
||||
|
||||
func dispatchSessionOutput(filtered []byte, tracker *terminalstate.Tracker, replay *ReplayBuffer, connector SessionConnector) {
|
||||
if len(filtered) == 0 {
|
||||
return
|
||||
}
|
||||
replay.Add(filtered)
|
||||
if tracker != nil {
|
||||
_ = tracker.Feed(filtered)
|
||||
}
|
||||
connector.OnData(filtered)
|
||||
}
|
||||
|
||||
func snapshotFromTracker(tracker *terminalstate.Tracker, width, height int) terminalstate.Snapshot {
|
||||
if tracker != nil {
|
||||
return tracker.Snapshot()
|
||||
}
|
||||
if height < 0 {
|
||||
height = 0
|
||||
}
|
||||
return terminalstate.Snapshot{
|
||||
Width: width,
|
||||
Height: height,
|
||||
Buffer: make([][]terminalstate.Cell, height),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package webterm
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/rcarmo/webterm-go-port/internal/terminalstate"
|
||||
)
|
||||
|
||||
type captureConnector struct {
|
||||
data [][]byte
|
||||
}
|
||||
|
||||
func (c *captureConnector) OnData(data []byte) {
|
||||
c.data = append(c.data, append([]byte{}, data...))
|
||||
}
|
||||
|
||||
func (c *captureConnector) OnBinary([]byte) {}
|
||||
func (c *captureConnector) OnMeta(map[string]any) {}
|
||||
func (c *captureConnector) OnClose() {}
|
||||
|
||||
func TestDispatchSessionOutput(t *testing.T) {
|
||||
replay := NewReplayBuffer(1024)
|
||||
connector := &captureConnector{}
|
||||
tracker := terminalstate.NewTracker(80, 24)
|
||||
|
||||
dispatchSessionOutput([]byte("hello\n"), tracker, replay, connector)
|
||||
if got := string(replay.Bytes()); got != "hello\n" {
|
||||
t.Fatalf("unexpected replay: %q", got)
|
||||
}
|
||||
if len(connector.data) != 1 || string(connector.data[0]) != "hello\n" {
|
||||
t.Fatalf("unexpected connector data: %+v", connector.data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchSessionOutputEmpty(t *testing.T) {
|
||||
replay := NewReplayBuffer(1024)
|
||||
connector := &captureConnector{}
|
||||
|
||||
dispatchSessionOutput(nil, nil, replay, connector)
|
||||
dispatchSessionOutput([]byte{}, nil, replay, connector)
|
||||
|
||||
if got := string(replay.Bytes()); got != "" {
|
||||
t.Fatalf("expected empty replay, got %q", got)
|
||||
}
|
||||
if len(connector.data) != 0 {
|
||||
t.Fatalf("expected no connector events")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshotFromTrackerFallback(t *testing.T) {
|
||||
snap := snapshotFromTracker(nil, 10, -2)
|
||||
if snap.Width != 10 || snap.Height != 0 || len(snap.Buffer) != 0 {
|
||||
t.Fatalf("unexpected fallback snapshot: %+v", snap)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshotFromTrackerWithTracker(t *testing.T) {
|
||||
tracker := terminalstate.NewTracker(4, 2)
|
||||
_ = tracker.Feed([]byte("ab"))
|
||||
snap := snapshotFromTracker(tracker, 1, 1)
|
||||
if snap.Width != 4 || snap.Height != 2 {
|
||||
t.Fatalf("unexpected tracker snapshot dimensions: %+v", snap)
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 50 KiB |
Executable
BIN
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "Webterm Dashboard",
|
||||
"short_name": "webterm",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#0d1117",
|
||||
"theme_color": "#0d1117",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/static/icons/webterm-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/static/icons/webterm-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/* Generic monospace font stack for terminal rendering.
|
||||
|
||||
Prefers system monospace fonts, with optional Fira Code / Roboto Mono if available.
|
||||
We avoid external font fetching (e.g. Google Fonts) to keep local server self-contained.
|
||||
*/
|
||||
|
||||
:root {
|
||||
--webterm-mono: ui-monospace, "SFMono-Regular", "FiraCode Nerd Font",
|
||||
"FiraMono Nerd Font", "Fira Code", "Roboto Mono", Menlo, Monaco, Consolas,
|
||||
"Liberation Mono", "DejaVu Sans Mono", "Courier New", monospace;
|
||||
--terminal-min-width: 10px;
|
||||
--terminal-min-height: 5px;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
font-family: var(--webterm-mono);
|
||||
}
|
||||
|
||||
/* Prevent scrollbar gutter space reservation */
|
||||
html {
|
||||
scrollbar-gutter: auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Terminal container - works with ghostty-web canvas renderer */
|
||||
.webterm-terminal {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: var(--terminal-min-width);
|
||||
min-height: var(--terminal-min-height);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
contain: strict; /* Performance optimization */
|
||||
}
|
||||
|
||||
/* ghostty-web renders to a canvas element */
|
||||
.webterm-terminal canvas {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Hidden textarea for keyboard input */
|
||||
.webterm-terminal textarea {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* High DPI display handling */
|
||||
@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi) {
|
||||
.webterm-terminal {
|
||||
/* ghostty-web handles DPI scaling via devicePixelRatio */
|
||||
}
|
||||
}
|
||||
|
||||
/* Fallback for older browsers */
|
||||
@supports not (display: flex) {
|
||||
.webterm-terminal {
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
}
|
||||
@@ -136,15 +136,7 @@ func (s *TerminalSession) handleOutput(data []byte) {
|
||||
tracker := s.tracker
|
||||
connector := s.connector
|
||||
s.mu.Unlock()
|
||||
|
||||
if len(filtered) == 0 {
|
||||
return
|
||||
}
|
||||
s.replay.Add(filtered)
|
||||
if tracker != nil {
|
||||
_ = tracker.Feed(filtered)
|
||||
}
|
||||
connector.OnData(filtered)
|
||||
dispatchSessionOutput(filtered, tracker, s.replay, connector)
|
||||
}
|
||||
|
||||
func (s *TerminalSession) Close() error {
|
||||
@@ -233,15 +225,9 @@ func (s *TerminalSession) GetReplayBuffer() []byte {
|
||||
func (s *TerminalSession) GetScreenSnapshot() terminalstate.Snapshot {
|
||||
s.mu.RLock()
|
||||
tracker := s.tracker
|
||||
width, height := s.width, s.height
|
||||
s.mu.RUnlock()
|
||||
if tracker == nil {
|
||||
return terminalstate.Snapshot{
|
||||
Width: s.width,
|
||||
Height: s.height,
|
||||
Buffer: make([][]terminalstate.Cell, s.height),
|
||||
}
|
||||
}
|
||||
return tracker.Snapshot()
|
||||
return snapshotFromTracker(tracker, width, height)
|
||||
}
|
||||
|
||||
func (s *TerminalSession) UpdateConnector(connector SessionConnector) {
|
||||
|
||||
Reference in New Issue
Block a user