Skip to content

fix(terminal): prevent multi-tab disconnect from killing shared control-mode process#107

Merged
tstapler merged 6 commits into
mainfrom
stapler-squad-multiple-tabs
Jun 12, 2026
Merged

fix(terminal): prevent multi-tab disconnect from killing shared control-mode process#107
tstapler merged 6 commits into
mainfrom
stapler-squad-multiple-tabs

Conversation

@tstapler

Copy link
Copy Markdown
Owner

Summary

  • Root cause: When any WebSocket client disconnected, StopControlMode() killed the shared tmux control-mode process and closed all subscriber channels, leaving every other open browser window/tab with a broken terminal showing "open terminal failed: terminal does not support clear"
  • Fix: Add refcount-based lifecycle (controlModeRefCount) so the process stays alive until the last subscriber stops; a serializing controlModeStartMu mutex prevents the TOCTOU race where two concurrent first-time callers could fork two tmux processes
  • Bonus fix: Upgrade broadcastControlModeUpdate from RLockLock to prevent a send-on-closed-channel panic when a subscriber unsubscribes concurrently (RACE feat: Enhance session management and CLI #7)
  • Resize fix: Guard the pre-sizing getBoundingClientRect() call behind containerSize.width > 0 (from ResizeObserver) so split-pane terminals send their actual pane width rather than the full browser window width in the PTY handshake

Changes

session/tmux/tmux.go

  • Added controlModeStartMu sync.Mutex (serializes Start/Stop) and controlModeRefCount int fields to TmuxSession

session/tmux/control_mode.go

  • StartControlMode(): if process already running, increment refcount and return (no new fork); first caller acquires startMu for the full startup sequence
  • StopControlMode(): decrement refcount; only tear down when refcount reaches 0
  • broadcastControlModeUpdate(): upgraded from RLock to Lock to prevent concurrent close/send races

session/tmux/control_mode_refcount_test.go (new)

  • 6 tests covering the refcount invariants, all passing under -race

web-app/src/components/sessions/TerminalOutput.tsx

  • Pre-sizing effect now waits for ResizeObserver (containerSize.width > 0) before reading getBoundingClientRect(), preventing the wrong full-window-width being sent as the initial PTY size in split-pane mode
  • When containerSize becomes non-zero after the initial render, the effect now also calls connect() directly if not yet initiated (session-switch effect won't re-run since sessionId unchanged)

web-app/jest.setup.js

  • Added global ResizeObserver stub that fires the callback synchronously with getBoundingClientRect() when observe() is called, enabling tests that mock getBoundingClientRect to control the reported container size

Test plan

  • go test ./session/tmux/... -count=1 -race — 206 tests pass (including 6 new refcount tests)
  • npx jest --no-coverage --testPathPatterns="TerminalOutput" — all 74 tests pass (were 74 failures before ResizeObserver stub fix)
  • Full frontend suite: 1608 pass, 90 fail (all 90 are pre-existing jsdom limitations: window.matchMedia, canvas, scrollIntoView)

🤖 Generated with Claude Code

tstapler and others added 3 commits June 9, 2026 14:21
…pture for OneShot

Two improvements to OneShot (-p) session control:

1. **Fix initial prompt not submitting** (\r instead of \n): The session driver
   was sending initialPrompt + "\n" (LF) to the PTY. Claude Code's interactive
   readline needs \r (CR) to submit — the same signal a physical Enter key sends.
   Sessions received the typed text but never executed it, causing inactivity
   timeouts. Confirmed by log: "sent initial prompt" followed by 10-min idle.
   The startup dialog answers ("1\n") work because those menus handle both,
   but Claude's readline interface only responds to \r.

2. **Capture claude session_id from --output-format json output**: OneShot
   sessions now launch with -p --output-format json. When the session exits,
   the driver parses the JSON output for "session_id" and stores it as
   ConversationUUID on the instance. Subsequent restarts automatically use
   --resume <uuid>, sending Claude back into the same conversation with full
   context instead of re-running the task from scratch.

   Supporting infrastructure:
   - SetClaudeConversationUUID() — thread-safe setter that fires a save callback
   - SetClaudeSessionIDSavedCallback() — wired in service layer to flush to DB
   - wireClaudeSessionIDCallback() — registered on all creation paths including
     loadInstancesWithWiring (startup) and CreateDirectorySession (backlog)
   - Prompt is now appended even when claudeSessionID is set for OneShot
     sessions so continuation prompts are delivered after --resume

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
New JSON columns (programs, subcommands, etc.) were NOT NULL with no
SQL-level DEFAULT, causing the SQLite copy-table migration to fail for
existing rows. Adding Optional() makes the columns nullable so old rows
can be copied; new rows still get []string{} from the Go-level Default.

Also adds build backup + auto-rollback to install-service:
- make install-service saves stapler-squad.prev before building
- health check polls /health for 15s after service start
- on failure, auto-restores .prev and restarts the service
- make rollback provides a manual escape hatch

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ol-mode process

When multiple browser windows or split panes subscribe to the same tmux
session's control-mode stream, disconnecting any one WebSocket was calling
StopControlMode() which killed the shared tmux process and closed all
subscriber channels — leaving the remaining windows with a broken terminal.

Add refcount-based lifecycle to StartControlMode/StopControlMode: the
process stays alive until every subscriber has stopped. A serializing
mutex prevents the TOCTOU race where two concurrent first-time callers
could fork two tmux processes. Upgrade broadcastControlModeUpdate from
RLock to Lock to prevent a send-on-closed-channel panic when a subscriber
unsubscribes concurrently.

Also guard the pre-sizing getBoundingClientRect() call behind the
ResizeObserver's containerSize, ensuring split-pane terminals send their
actual pane width (not the full browser window width) in the PTY handshake.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings June 12, 2026 00:52
@github-actions

Copy link
Copy Markdown
Contributor

✅ Registry Validation

Registry Validation
===================

Building backend scanner...
Scanning backend features...
Wrote 97 feature files to /tmp/tmp.XoYAr3oq5t/backend
Wrote 14 feature files to /tmp/tmp.XoYAr3oq5t/backend
Wrote 22 feature files to /tmp/tmp.XoYAr3oq5t/backend
Wrote 6 feature files to /tmp/tmp.XoYAr3oq5t/backend

=== Backend Registry Diff ===
Committed: 131  Generated: 130  Divergence: 0.76%
⚠️  Removed RPCs:
  - upload:image
⚠️  90 feature(s) missing // +api: marker (markerFound: false)

✅ Registry validation passed. Divergence: 0.76%

Test Coverage: 93/131 features have testIds (71.0%)

Registry validation is in observation mode until 2026-05-02.
After that date, divergence > 2% will block merges.
Coverage reporting is advisory only.

@github-actions

github-actions Bot commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Go Benchmarks (Tier 1)

benchmarks/go/tier1-baseline.txt:8: parsing iteration count: invalid syntax
benchmarks/go/tier1-baseline.txt:1911: parsing iteration count: invalid syntax
benchmarks/go/tier1-baseline.txt:3851: parsing iteration count: invalid syntax
benchmarks/go/tier1-baseline.txt:5818: parsing iteration count: invalid syntax
benchmarks/go/tier1-baseline.txt:7757: parsing iteration count: invalid syntax
benchmarks/go/tier1-baseline.txt:9765: parsing iteration count: invalid syntax
benchmarks/go/tier1-baseline.txt:11762: parsing iteration count: invalid syntax
benchmarks/go/tier1-baseline.txt:13718: parsing iteration count: invalid syntax
benchmarks/go/tier1-baseline.txt:15713: parsing iteration count: invalid syntax
benchmarks/go/tier1-baseline.txt:22046: parsing iteration count: invalid syntax
benchmarks/go/tier1-baseline.txt:28331: parsing iteration count: invalid syntax
benchmarks/go/tier1-baseline.txt:34667: parsing iteration count: invalid syntax
benchmarks/go/tier1-baseline.txt:41112: parsing iteration count: invalid syntax
benchmarks/go/tier1-baseline.txt:47519: parsing iteration count: invalid syntax
benchmarks/go/tier1-baseline.txt:54111: parsing iteration count: invalid syntax
benchmarks/go/tier1-baseline.txt:60936: parsing iteration count: invalid syntax
benchmarks/go/tier1-baseline.txt:67663: parsing iteration count: invalid syntax
benchmarks/go/tier1-baseline.txt:72937: parsing iteration count: invalid syntax
benchmarks/go/tier1-baseline.txt:78608: parsing iteration count: invalid syntax
benchmarks/go/tier1-baseline.txt:84042: parsing iteration count: invalid syntax
benchmarks/go/tier1-baseline.txt:89486: parsing iteration count: invalid syntax
benchmarks/go/tier1-baseline.txt:95390: parsing iteration count: invalid syntax
benchmarks/go/tier1-baseline.txt:100594: parsing iteration count: invalid syntax
benchmarks/go/tier1-baseline.txt:105408: parsing iteration count: invalid syntax
benchmarks/go/tier1-baseline.txt:111071: parsing iteration count: invalid syntax
benchmarks/go/tier1-baseline.txt:117978: parsing iteration count: invalid syntax
benchmarks/go/tier1-baseline.txt:125832: parsing iteration count: invalid syntax
benchmarks/go/tier1-baseline.txt:132503: parsing iteration count: invalid syntax
benchmarks/go/tier1-baseline.txt:140205: parsing iteration count: invalid syntax
benchmarks/go/tier1-baseline.txt:146776: parsing iteration count: invalid syntax
benchmarks/go/tier1-baseline.txt:153558: parsing iteration count: invalid syntax
benchmarks/go/tier1-baseline.txt:160254: parsing iteration count: invalid syntax
benchmarks/go/tier1-baseline.txt:167029: parsing iteration count: invalid syntax
benchmarks/go/tier1-baseline.txt:174225: parsing iteration count: invalid syntax
benchmarks/go/tier1-baseline.txt:181126: parsing iteration count: invalid syntax
benchmarks/go/tier1-baseline.txt:188418: parsing iteration count: invalid syntax
benchmarks/go/tier1-baseline.txt:196178: parsing iteration count: invalid syntax
benchmarks/go/tier1-baseline.txt:204037: parsing iteration count: invalid syntax
benchmarks/go/tier1-baseline.txt:211583: parsing iteration count: invalid syntax
benchmarks/go/tier1-baseline.txt:219315: parsing iteration count: invalid syntax
tier1-bench.txt:8: parsing iteration count: invalid syntax
tier1-bench.txt:950: parsing iteration count: invalid syntax
tier1-bench.txt:2544: parsing iteration count: invalid syntax
tier1-bench.txt:4169: parsing iteration count: invalid syntax
tier1-bench.txt:5810: parsing iteration count: invalid syntax
tier1-bench.txt:7430: parsing iteration count: invalid syntax
tier1-bench.txt:9022: parsing iteration count: invalid syntax
tier1-bench.txt:10619: parsing iteration count: invalid syntax
tier1-bench.txt:12206: parsing iteration count: invalid syntax
tier1-bench.txt:17604: parsing iteration count: invalid syntax
tier1-bench.txt:23330: parsing iteration count: invalid syntax
tier1-bench.txt:28747: parsing iteration count: invalid syntax
tier1-bench.txt:34104: parsing iteration count: invalid syntax
tier1-bench.txt:39630: parsing iteration count: invalid syntax
tier1-bench.txt:45227: parsing iteration count: invalid syntax
tier1-bench.txt:51245: parsing iteration count: invalid syntax
tier1-bench.txt:56396: parsing iteration count: invalid syntax
tier1-bench.txt:61040: parsing iteration count: invalid syntax
tier1-bench.txt:65728: parsing iteration count: invalid syntax
tier1-bench.txt:70472: parsing iteration count: invalid syntax
tier1-bench.txt:75223: parsing iteration count: invalid syntax
tier1-bench.txt:80029: parsing iteration count: invalid syntax
tier1-bench.txt:84492: parsing iteration count: invalid syntax
tier1-bench.txt:88963: parsing iteration count: invalid syntax
tier1-bench.txt:93856: parsing iteration count: invalid syntax
tier1-bench.txt:100207: parsing iteration count: invalid syntax
tier1-bench.txt:107139: parsing iteration count: invalid syntax
tier1-bench.txt:113487: parsing iteration count: invalid syntax
tier1-bench.txt:120014: parsing iteration count: invalid syntax
tier1-bench.txt:126554: parsing iteration count: invalid syntax
tier1-bench.txt:133831: parsing iteration count: invalid syntax
tier1-bench.txt:141149: parsing iteration count: invalid syntax
tier1-bench.txt:147689: parsing iteration count: invalid syntax
tier1-bench.txt:153560: parsing iteration count: invalid syntax
tier1-bench.txt:159549: parsing iteration count: invalid syntax
tier1-bench.txt:165784: parsing iteration count: invalid syntax
tier1-bench.txt:171422: parsing iteration count: invalid syntax
tier1-bench.txt:177236: parsing iteration count: invalid syntax
tier1-bench.txt:183130: parsing iteration count: invalid syntax
tier1-bench.txt:188881: parsing iteration count: invalid syntax
goos: linux
goarch: amd64
pkg: github.com/tstapler/stapler-squad/session/detection/ratelimit
cpu: AMD EPYC 9V74 80-Core Processor                
                              │ benchmarks/go/tier1-baseline.txt │
                              │              sec/op              │
StripANSI_PlainText-4                                6.867n ± 3%
StripANSI_WithEscapes-4                              659.6n ± 0%
ProcessOutput_InactiveState-4                        6.635n ± 1%
geomean                                              31.09n

                              │ benchmarks/go/tier1-baseline.txt │
                              │               B/op               │
StripANSI_PlainText-4                               0.000 ± 0%
StripANSI_WithEscapes-4                             136.0 ± 0%
ProcessOutput_InactiveState-4                       0.000 ± 0%
geomean                                                        ¹
¹ summaries must be >0 to compute geomean

                              │ benchmarks/go/tier1-baseline.txt │
                              │            allocs/op             │
StripANSI_PlainText-4                               0.000 ± 0%
StripANSI_WithEscapes-4                             5.000 ± 0%
ProcessOutput_InactiveState-4                       0.000 ± 0%
geomean                                                        ¹
¹ summaries must be >0 to compute geomean

cpu: Intel(R) Xeon(R) Platinum 8370C CPU @ 2.80GHz
                              │ tier1-bench.txt │
                              │     sec/op      │
StripANSI_PlainText-4               6.579n ± 2%
StripANSI_WithEscapes-4             690.9n ± 0%
ProcessOutput_InactiveState-4       17.02n ± 0%
geomean                             42.61n

                              │ tier1-bench.txt │
                              │      B/op       │
StripANSI_PlainText-4              0.000 ± 0%
StripANSI_WithEscapes-4            136.0 ± 0%
ProcessOutput_InactiveState-4      0.000 ± 0%
geomean                                       ¹
¹ summaries must be >0 to compute geomean

                              │ tier1-bench.txt │
                              │    allocs/op    │
StripANSI_PlainText-4              0.000 ± 0%
StripANSI_WithEscapes-4            5.000 ± 0%
ProcessOutput_InactiveState-4      0.000 ± 0%
geomean                                       ¹
¹ summaries must be >0 to compute geomean

pkg: github.com/tstapler/stapler-squad/session/queue
cpu: AMD EPYC 9V74 80-Core Processor                
                              │ benchmarks/go/tier1-baseline.txt │
                              │              sec/op              │
ReviewQueue_ConcurrentReads-4                       82.27n ± 14%
ReviewQueue_Add-4                                   409.1n ±  0%
geomean                                             183.4n

                              │ benchmarks/go/tier1-baseline.txt │
                              │               B/op               │
ReviewQueue_ConcurrentReads-4                       0.000 ± 0%
ReviewQueue_Add-4                                   640.0 ± 0%
geomean                                                        ¹
¹ summaries must be >0 to compute geomean

                              │ benchmarks/go/tier1-baseline.txt │
                              │            allocs/op             │
ReviewQueue_ConcurrentReads-4                       0.000 ± 0%
ReviewQueue_Add-4                                   4.000 ± 0%
geomean                                                        ¹
¹ summaries must be >0 to compute geomean

cpu: Intel(R) Xeon(R) Platinum 8370C CPU @ 2.80GHz
                              │ tier1-bench.txt │
                              │     sec/op      │
ReviewQueue_ConcurrentReads-4       90.89n ± 2%
ReviewQueue_Add-4                   380.2n ± 0%
geomean                             185.9n

                              │ tier1-bench.txt │
                              │      B/op       │
ReviewQueue_ConcurrentReads-4      0.000 ± 0%
ReviewQueue_Add-4                  640.0 ± 0%
geomean                                       ¹
¹ summaries must be >0 to compute geomean

                              │ tier1-bench.txt │
                              │    allocs/op    │
ReviewQueue_ConcurrentReads-4      0.000 ± 0%
ReviewQueue_Add-4                  4.000 ± 0%
geomean                                       ¹
¹ summaries must be >0 to compute geomean

pkg: github.com/tstapler/stapler-squad/session/scrollback
cpu: AMD EPYC 9V74 80-Core Processor                
                                      │ benchmarks/go/tier1-baseline.txt │
                                      │              sec/op              │
CircularBuffer_ConcurrentReadWrite-4                         3.135µ ± 1%
CircularBuffer_BurstAppend-4                                 104.8µ ± 0%
CircularBuffer_GetLastN_LargeBuffer-4                        16.12µ ± 4%
CircularBuffer_GetRange_Sequential-4                         9.207µ ± 5%
CircularBufferAppend-4                                       103.0n ± 0%
CircularBufferGetLastN-4                                     1.775µ ± 1%
CircularBufferConcurrentAppend-4                             135.0n ± 0%
geomean                                                      2.754µ

                                      │ benchmarks/go/tier1-baseline.txt │
                                      │               B/op               │
CircularBuffer_ConcurrentReadWrite-4                        6.062Ki ± 0%
CircularBuffer_BurstAppend-4                                62.50Ki ± 0%
CircularBuffer_GetLastN_LargeBuffer-4                       56.00Ki ± 0%
CircularBuffer_GetRange_Sequential-4                        28.00Ki ± 0%
CircularBufferAppend-4                                        24.00 ± 0%
CircularBufferGetLastN-4                                    6.000Ki ± 0%
CircularBufferConcurrentAppend-4                              32.00 ± 0%
geomean                                                     3.077Ki

                                      │ benchmarks/go/tier1-baseline.txt │
                                      │            allocs/op             │
CircularBuffer_ConcurrentReadWrite-4                          2.000 ± 0%
CircularBuffer_BurstAppend-4                                 1.000k ± 0%
CircularBuffer_GetLastN_LargeBuffer-4                         1.000 ± 0%
CircularBuffer_GetRange_Sequential-4                          1.000 ± 0%
CircularBufferAppend-4                                        1.000 ± 0%
CircularBufferGetLastN-4                                      1.000 ± 0%
CircularBufferConcurrentAppend-4                              1.000 ± 0%
geomean                                                       2.962

                             │ benchmarks/go/tier1-baseline.txt │
                             │               B/s                │
CircularBuffer_BurstAppend-4                       582.6Mi ± 0%

cpu: Intel(R) Xeon(R) Platinum 8370C CPU @ 2.80GHz
                                      │ tier1-bench.txt │
                                      │     sec/op      │
CircularBuffer_ConcurrentReadWrite-4        3.072µ ± 1%
CircularBuffer_BurstAppend-4                113.6µ ± 0%
CircularBuffer_GetLastN_LargeBuffer-4       19.33µ ± 2%
CircularBuffer_GetRange_Sequential-4        10.80µ ± 3%
CircularBufferAppend-4                      108.0n ± 1%
CircularBufferGetLastN-4                    2.030µ ± 1%
CircularBufferConcurrentAppend-4            155.8n ± 5%
geomean                                     3.056µ

                                      │ tier1-bench.txt │
                                      │      B/op       │
CircularBuffer_ConcurrentReadWrite-4       6.062Ki ± 0%
CircularBuffer_BurstAppend-4               62.50Ki ± 0%
CircularBuffer_GetLastN_LargeBuffer-4      56.00Ki ± 0%
CircularBuffer_GetRange_Sequential-4       28.00Ki ± 0%
CircularBufferAppend-4                       24.00 ± 0%
CircularBufferGetLastN-4                   6.000Ki ± 0%
CircularBufferConcurrentAppend-4             32.00 ± 0%
geomean                                    3.077Ki

                                      │ tier1-bench.txt │
                                      │    allocs/op    │
CircularBuffer_ConcurrentReadWrite-4         2.000 ± 0%
CircularBuffer_BurstAppend-4                1.000k ± 0%
CircularBuffer_GetLastN_LargeBuffer-4        1.000 ± 0%
CircularBuffer_GetRange_Sequential-4         1.000 ± 0%
CircularBufferAppend-4                       1.000 ± 0%
CircularBufferGetLastN-4                     1.000 ± 0%
CircularBufferConcurrentAppend-4             1.000 ± 0%
geomean                                      2.962

                             │ tier1-bench.txt │
                             │       B/s       │
CircularBuffer_BurstAppend-4      537.4Mi ± 0%

pkg: github.com/tstapler/stapler-squad/session/tmux
cpu: AMD EPYC 9V74 80-Core Processor                
                             │ benchmarks/go/tier1-baseline.txt │
                             │              sec/op              │
StripANSICodes_PlainText-4                          6.550n ± 3%
StripANSICodes_WithEscapes-4                        602.0n ± 1%
IsBanner_PlainText-4                                452.6n ± 2%
geomean                                             121.3n

                             │ benchmarks/go/tier1-baseline.txt │
                             │               B/op               │
StripANSICodes_PlainText-4                         0.000 ± 0%
StripANSICodes_WithEscapes-4                       56.00 ± 0%
IsBanner_PlainText-4                               0.000 ± 0%
geomean                                                       ¹
¹ summaries must be >0 to compute geomean

                             │ benchmarks/go/tier1-baseline.txt │
                             │            allocs/op             │
StripANSICodes_PlainText-4                         0.000 ± 0%
StripANSICodes_WithEscapes-4                       4.000 ± 0%
IsBanner_PlainText-4                               0.000 ± 0%
geomean                                                       ¹
¹ summaries must be >0 to compute geomean

cpu: Intel(R) Xeon(R) Platinum 8370C CPU @ 2.80GHz
                             │ tier1-bench.txt │
                             │     sec/op      │
StripANSICodes_PlainText-4         6.867n ± 1%
StripANSICodes_WithEscapes-4       638.4n ± 0%
IsBanner_PlainText-4               438.6n ± 0%
geomean                            124.3n

                             │ tier1-bench.txt │
                             │      B/op       │
StripANSICodes_PlainText-4        0.000 ± 0%
StripANSICodes_WithEscapes-4      56.00 ± 0%
IsBanner_PlainText-4              0.000 ± 0%
geomean                                      ¹
¹ summaries must be >0 to compute geomean

                             │ tier1-bench.txt │
                             │    allocs/op    │
StripANSICodes_PlainText-4        0.000 ± 0%
StripANSICodes_WithEscapes-4      4.000 ± 0%
IsBanner_PlainText-4              0.000 ± 0%
geomean                                      ¹
¹ summaries must be >0 to compute geomean

pkg: github.com/tstapler/stapler-squad/session/tokens
cpu: AMD EPYC 9V74 80-Core Processor                
                                   │ benchmarks/go/tier1-baseline.txt │
                                   │              sec/op              │
TokenParser_ProcessUserEntry-4                            5.547m ± 2%
DetectCommandsInText/NoSlash-4                            6.696n ± 1%
DetectCommandsInText/WithCommand-4                        1.444µ ± 0%
geomean                                                   3.771µ

                                   │ benchmarks/go/tier1-baseline.txt │
                                   │               B/op               │
TokenParser_ProcessUserEntry-4                         11.02Mi ± 0%
DetectCommandsInText/NoSlash-4                           0.000 ± 0%
DetectCommandsInText/WithCommand-4                       434.0 ± 0%
geomean                                                             ¹
¹ summaries must be >0 to compute geomean

                                   │ benchmarks/go/tier1-baseline.txt │
                                   │            allocs/op             │
TokenParser_ProcessUserEntry-4                           34.00 ± 0%
DetectCommandsInText/NoSlash-4                           0.000 ± 0%
DetectCommandsInText/WithCommand-4                       6.000 ± 0%
geomean                                                             ¹
¹ summaries must be >0 to compute geomean

cpu: Intel(R) Xeon(R) Platinum 8370C CPU @ 2.80GHz
                                   │ tier1-bench.txt │
                                   │     sec/op      │
TokenParser_ProcessUserEntry-4           4.655m ± 2%
DetectCommandsInText/NoSlash-4           6.679n ± 0%
DetectCommandsInText/WithCommand-4       1.482µ ± 0%
geomean                                  3.585µ

                                   │ tier1-bench.txt │
                                   │      B/op       │
TokenParser_ProcessUserEntry-4        11.02Mi ± 0%
DetectCommandsInText/NoSlash-4          0.000 ± 0%
DetectCommandsInText/WithCommand-4      433.0 ± 0%
geomean                                            ¹
¹ summaries must be >0 to compute geomean

                                   │ tier1-bench.txt │
                                   │    allocs/op    │
TokenParser_ProcessUserEntry-4          34.00 ± 0%
DetectCommandsInText/NoSlash-4          0.000 ± 0%
DetectCommandsInText/WithCommand-4      6.000 ± 0%
geomean                                            ¹
¹ summaries must be >0 to compute geomean

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR addresses a production terminal reliability issue where disconnecting one browser tab could tear down a shared tmux control-mode process and break other active tabs. It introduces a refcounted lifecycle for tmux control mode, improves frontend initial sizing behavior in split-pane layouts, and includes some additional operational + DB schema adjustments.

Changes:

  • Add refcount + serialized start/stop to tmux control-mode so the underlying process stays alive until the last subscriber disconnects, and harden broadcast against unsubscribe races.
  • Update TerminalOutput pre-sizing to wait for real pane dimensions (ResizeObserver) before using getBoundingClientRect(), and add a Jest ResizeObserver stub for tests.
  • Make several ApprovalRule JSON criteria fields nullable in ent schema/migrations and add generated clear/reset plumbing; add install-service health check/rollback support plus Makefile targets.

Reviewed changes

Copilot reviewed 10 out of 15 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
web-app/src/components/sessions/TerminalOutput.tsx Defer pre-sizing until pane layout is resolved; optionally initiate connect once real size is known.
web-app/jest.setup.js Add jsdom ResizeObserver stub to enable deterministic sizing tests.
session/tmux/tmux.go Extend TmuxSession with control-mode start mutex + refcount fields.
session/tmux/control_mode.go Refcount-based Start/Stop lifecycle; broadcast uses write lock to avoid close/send races.
session/tmux/control_mode_refcount_test.go Add tests for refcount invariants and broadcast/unsubscribe race regression.
session/session_driver.go Remove a trailing blank line (formatting-only).
session/instance_claude.go Remove a trailing blank line (formatting-only).
session/ent/schema/approvalrule.go Make JSON criteria fields nullable (Optional) with defaults for migration compatibility.
session/ent/mutation.go Generated mutation updates: add Clear*/Cleared* support for nullable JSON fields.
session/ent/migrate/schema.go Generated schema updates: mark JSON columns Nullable.
session/ent/approvalrule/where.go Generated predicates: add IsNil/NotNil helpers for nullable JSON fields.
session/ent/approvalrule_update.go Generated updates: add Clear* methods and SQL clear handling for nullable JSON fields.
session/ent/approvalrule_create.go Generated create/upsert updates aligning with nullable JSON fields.
scripts/install-service.sh Add post-install health check with auto-rollback to .prev binary.
Makefile Add backup-binary + rollback targets and wire backup into install-service.
Files not reviewed (5)
  • session/ent/approvalrule/where.go: Generated file
  • session/ent/approvalrule_create.go: Generated file
  • session/ent/approvalrule_update.go: Generated file
  • session/ent/migrate/schema.go: Generated file
  • session/ent/mutation.go: Generated file
Comments suppressed due to low confidence (1)

Makefile:280

  • The PR description doesn’t mention the new Make targets (backup-binary/rollback) and the changed install-service dependency chain. Since this affects developer/service install flows, it should be documented in the PR description (and ideally in any install docs) or split into a separate PR.
backup-binary: ## Snapshot the current binary to stapler-squad.prev before a new build (called by install-service)
	@if [ -f ./stapler-squad ]; then \
		cp -f ./stapler-squad ./stapler-squad.prev; \
		echo "==> Saved current binary to ./stapler-squad.prev"; \
	fi

install-service: backup-binary build ## Install stapler-squad as a system service (systemd on Linux, LaunchAgent on macOS)
ifeq ($(UNAME_S),Darwin)
	@$(MAKE) _codesign-binary
endif
	@STAPLER_SQUAD_BIN="$(CURDIR)/stapler-squad" ./scripts/install-service.sh $(if $(NO_PROFILE),--no-profile) $(if $(PROFILE_PORT),--profile-port $(PROFILE_PORT))

rollback: ## Restore the previous build (stapler-squad.prev) and restart the service
	@if [ ! -f ./stapler-squad.prev ]; then \
		echo "✗ No previous build found (./stapler-squad.prev does not exist)"; \
		exit 1; \
	fi
	@echo "==> Restoring previous build..."
	@cp -f ./stapler-squad.prev ./stapler-squad
	@echo "✓ Binary restored from stapler-squad.prev"
ifeq ($(UNAME_S),Darwin)
	@$(MAKE) _codesign-binary
endif
	@STAPLER_SQUAD_BIN="$(CURDIR)/stapler-squad" ./scripts/install-service.sh $(if $(NO_PROFILE),--no-profile) $(if $(PROFILE_PORT),--profile-port $(PROFILE_PORT))

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +276 to +277
url="http://localhost:8543/health"
printf "==> Waiting for service to be healthy"

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file is outside the scope of this PR (introduced in a prior commit). Deferring the curl guard to a follow-up.

Comment on lines +103 to +133
// TestRefcount_LastStopActuallyTearesDown verifies that when refcount reaches 0,
// StopControlMode closes controlModeDone and nils controlModeCmd.
func TestRefcount_LastStopActuallyTearesDown(t *testing.T) {
sess := newRefcountTestSession(t)
doneCh := sess.controlModeDone

// Simulate teardown path: we can't call real StopControlMode on a fake cmd
// (it would try to kill a nil process), so we validate the refcount logic inline
// by checking what happens when refcount reaches 0 via the explicit decrement path.

// Manually decrement to 0 and check that the guard passes.
sess.controlModeSubMu.Lock()
sess.controlModeRefCount = 1
sess.controlModeRefCount--
remaining := sess.controlModeRefCount
if sess.controlModeDone != nil {
close(sess.controlModeDone)
sess.controlModeDone = nil
}
sess.controlModeSubMu.Unlock()

if remaining != 0 {
t.Errorf("remaining after final decrement = %d, want 0", remaining)
}
select {
case <-doneCh:
// Expected: channel was closed.
case <-time.After(100 * time.Millisecond):
t.Error("controlModeDone was not closed after final Stop")
}
}

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in the follow-up commit 9050c0d: TestRefcount_LastStopActuallyTearsDown now calls the real StopControlMode() and uses a real already-exited process, verifying controlModeCmd is nil and controlModeRefCount is 0 after teardown.

Comment on lines +103 to +105
// TestRefcount_LastStopActuallyTearesDown verifies that when refcount reaches 0,
// StopControlMode closes controlModeDone and nils controlModeCmd.
func TestRefcount_LastStopActuallyTearesDown(t *testing.T) {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in commit 9050c0d — renamed to TestRefcount_LastStopActuallyTearsDown.

Comment on lines +54 to 77
// Optional() makes the DB column nullable so SQLite migrations can copy old rows
// (which lack these columns) without hitting a NOT NULL constraint. New rows still
// get []string{} via Default(). Application code treats nil slice as empty.
field.JSON("programs", []string{}).
Optional().
Default([]string{}),
field.JSON("subcommands", []string{}).
Optional().
Default([]string{}),
field.JSON("blocked_subcommands", []string{}).
Optional().
Default([]string{}),
field.JSON("required_flags", []string{}).
Optional().
Default([]string{}),
field.JSON("forbidden_flags", []string{}).
Optional().
Default([]string{}),
field.JSON("required_flag_prefixes", []string{}).
Optional().
Default([]string{}),
field.JSON("python_modes", []string{}).
Optional().
Default([]string{}),

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ApprovalRule ent schema changes are from a prior commit on this branch (fix(db): make approval_rule JSON fields Optional to fix SQLite migration), not from the control-mode work. Noted — will split into separate PRs going forward for unrelated scope changes.

@github-actions

github-actions Bot commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

E2E RPC Latency

list-sessions-ttfb-mean: 5ms (▼ faster -16.5%; baseline: 7ms)
list-sessions-total-mean: 8ms (▼ faster -16.4%; baseline: 9ms)

@github-actions

github-actions Bot commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

UX Analysis

Check Status Details
✅ Axe Core (WCAG 2.1 AA) success Critical/serious violations block merge
⚠️ Lighthouse Performance Score: unknown Warning if < 70 (non-blocking)
🤖 Claude UX Analysis Advisory See docs/qa/ for findings

Axe Core excludes terminal rendering areas (intentional design).
Lighthouse runs in desktop preset for this developer tool.

@github-actions

github-actions Bot commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Frontend Terminal Throughput

terminal-throughput-mean: 17 KB/s ▲ +19.0% (baseline: 14 KB/s)
terminal-throughput-p50: 17 KB/s ▲ +5.2% (baseline: 16 KB/s)

@github-actions

github-actions Bot commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

🎬 E2E Feature Demos

2 shard(s) recorded feature flows for this PR.

recordings shard 1
recordings shard 2

Demo preview opens directly in browser (single-file HTML). Raw WebM recordings in ZIP. Expires after 30 days.

…t, channel race, ResetExitOnce

- Reset controlModeRefCount and controlModeCmd when tmux dies unilaterally
  (readControlModeOutput EOF path) so StartControlMode() can fork a fresh
  process rather than fast-returning against a dead cmd (ARCH-1 BLOCKER)
- Reset controlModeRefCount/Cmd in ResetExitOnce() for clean session restart
- Fix data race: read highPriSendCh/normPriSendCh under controlModeSubMu.RLock
  in send paths; nil them inside controlModeSubMu in StopControlMode (CQ-2)
- Close stderr pipe on cmd.Start() failure to fix fd leak
- Update tests: real StopControlMode teardown path, concurrent Start+Stop race,
  underflow guard coverage, broadcast post-condition assertions
- Fix typo TearesDown → TearsDown; fix misleading comment

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

✅ Registry Validation

Registry Validation
===================

Building backend scanner...
Scanning backend features...
Wrote 97 feature files to /tmp/tmp.OJ86TVKNP6/backend
Wrote 14 feature files to /tmp/tmp.OJ86TVKNP6/backend
Wrote 22 feature files to /tmp/tmp.OJ86TVKNP6/backend
Wrote 6 feature files to /tmp/tmp.OJ86TVKNP6/backend

=== Backend Registry Diff ===
Committed: 131  Generated: 130  Divergence: 0.76%
⚠️  Removed RPCs:
  - upload:image
⚠️  90 feature(s) missing // +api: marker (markerFound: false)

✅ Registry validation passed. Divergence: 0.76%

Test Coverage: 93/131 features have testIds (71.0%)

Registry validation is in observation mode until 2026-05-02.
After that date, divergence > 2% will block merges.
Coverage reporting is advisory only.

…elper

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

✅ Registry Validation

Registry Validation
===================

Building backend scanner...
Scanning backend features...
Wrote 97 feature files to /tmp/tmp.i7RBbPBNX4/backend
Wrote 14 feature files to /tmp/tmp.i7RBbPBNX4/backend
Wrote 22 feature files to /tmp/tmp.i7RBbPBNX4/backend
Wrote 6 feature files to /tmp/tmp.i7RBbPBNX4/backend

=== Backend Registry Diff ===
Committed: 131  Generated: 130  Divergence: 0.76%
⚠️  Removed RPCs:
  - upload:image
⚠️  90 feature(s) missing // +api: marker (markerFound: false)

✅ Registry validation passed. Divergence: 0.76%

Test Coverage: 93/131 features have testIds (71.0%)

Registry validation is in observation mode until 2026-05-02.
After that date, divergence > 2% will block merges.
Coverage reporting is advisory only.

…iles

Resolved 5 modify/delete conflicts in session/ent/ generated files
(approvalrule/where.go, approvalrule_create.go, approvalrule_update.go,
migrate/schema.go, mutation.go). Main untracked these via c568e00
"chore: untrack generated files". Accepted main's deletion for all five.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

✅ Registry Validation

Registry Validation
===================

Building backend scanner...
Scanning backend features...
Wrote 97 feature files to /tmp/tmp.iOPLalyNpc/backend
Wrote 14 feature files to /tmp/tmp.iOPLalyNpc/backend
Wrote 22 feature files to /tmp/tmp.iOPLalyNpc/backend
Wrote 6 feature files to /tmp/tmp.iOPLalyNpc/backend

=== Backend Registry Diff ===
Committed: 131  Generated: 130  Divergence: 0.76%
⚠️  Removed RPCs:
  - upload:image
⚠️  90 feature(s) missing // +api: marker (markerFound: false)

✅ Registry validation passed. Divergence: 0.76%

Test Coverage: 93/131 features have testIds (71.0%)

Registry validation is in observation mode until 2026-05-02.
After that date, divergence > 2% will block merges.
Coverage reporting is advisory only.

@tstapler tstapler merged commit 4314926 into main Jun 12, 2026
22 of 23 checks passed
@tstapler tstapler deleted the stapler-squad-multiple-tabs branch June 12, 2026 02:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants