Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions go/cmd/compass-app/bridge_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,33 @@ func (s *bridgeService) Connect(ctx context.Context, req connectRequest) connect
}
}

// cancelWindow cancels every in-flight call registered to a closing window and
// drops their entries, driving the same id-keyed teardown as compass_rpc_cancel
// (a canceled pump stops silently — no further frames — so the server-side
// subscription terminates). It is the close-time leak gate (record §M3b): a
// window closing without this leaves its calls' pump goroutines and server
// subscriptions live for the app's lifetime. Matching is by the comparable
// windowDispatcher the call captured at register time (bridge_service.go window
// field); a nil win matches nothing (fallback/windowless calls are never swept
// by a close). Cancels run outside the lock, matching CompassRPCCancel.
func (s *bridgeService) cancelWindow(win windowDispatcher) {
if win == nil {
return
}
s.mu.Lock()
var doomed []*inflightCall
for id, call := range s.inflight {
if call.window == win {
doomed = append(doomed, call)
delete(s.inflight, id)
}
}
s.mu.Unlock()
for _, call := range doomed {
call.cancel()
}
}

// register derives the forwarding context for a call and records the call's
// teardown handle under requestID, cancelling any prior call already under that
// id first (so a stale forwarder can never keep emitting onto the same event).
Expand Down
96 changes: 96 additions & 0 deletions go/cmd/compass-app/bridge_service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,19 @@ func assertNotInflight(t *testing.T, svc *bridgeService, requestID string) {
}
}

// assertInflight fails if requestID does NOT have an in-flight entry — the
// positive counterpart to assertNotInflight, used to prove a call survives a
// close-cancel that targeted a different window.
func assertInflight(t *testing.T, svc *bridgeService, requestID string) {
t.Helper()
svc.mu.Lock()
_, ok := svc.inflight[requestID]
svc.mu.Unlock()
if !ok {
t.Errorf("requestId %q not in-flight, want live entry", requestID)
}
}

// TestResponseFrameWireContract locks the JSON wire shape of every ResponseFrame
// kind against the JS contract (apps/ui/src/daemon-transport.ts:19-23). The
// load-bearing assertion is that head headers marshal as [name,value] TUPLE
Expand Down Expand Up @@ -783,3 +796,86 @@ func TestCompassRPCDestroyedWindowDropsFrames(t *testing.T) {
type destroyedWindow struct{}

func (destroyedWindow) dispatch(string, responseFrame) {}

// TestCancelWindowSweepsOnlyClosingWindow proves §M3b close-time cancel: ONE
// service holds four long-lived calls — TWO on winA (A1, A2), one on winB, and
// one with no window (the fallback/windowless path) — all kept in-flight by a
// stub handler that blocks until the test releases it. cancelWindow(winA) drops
// BOTH of winA's calls and nothing else: their entries are gone and their run
// goroutines return (each canceled pump stops → finish), while winB's call and
// the windowless call stay live and in-flight. Two calls on winA is the point —
// it pins the collect-and-cancel-ALL loop against a first-match-only regression
// (a stray break/return would leak A2 and this test would redden).
// cancelWindow(nil) then sweeps nothing. Finally the server is released so the
// survivors drain and the test exits clean. Event-gated on channels/waitDone,
// no sleeps.
func TestCancelWindowSweepsOnlyClosingWindow(t *testing.T) {
release := make(chan struct{})
handler := func(w http.ResponseWriter, _ *http.Request) {
<-release // block so each call's pump stays in-flight until released
w.Header().Set("Content-Type", "application/grpc-web+proto")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
}
socket := stubServer(t, handler)

svc, _ := newService(socket)
winA := newFakeWindow()
winB := newFakeWindow()
const idA1 = "req-winA-1"
const idA2 = "req-winA-2"
const idB = "req-winB"
const idC = "req-nowin"

// Launch the calls on the ONE service, each on its own run goroutine
// (CompassRPC's own register-then-go-run shape). The window is injected on
// the registered inflightCall directly because windowFromContext returns nil
// in this non-gtk3 test build. Each run signals done on return so teardown is
// event-gated, not raced.
launch := func(win windowDispatcher, req rpcRequest) chan struct{} {
callCtx, call := svc.register(context.Background(), req.RequestID)
call.window = win
done := make(chan struct{})
go func() {
defer close(done)
svc.run(callCtx, call, req)
}()
return done
}
doneA1 := launch(winA, rpcRequest{RequestID: idA1, Path: "/a1"})
doneA2 := launch(winA, rpcRequest{RequestID: idA2, Path: "/a2"})
doneB := launch(winB, rpcRequest{RequestID: idB, Path: "/b"})
doneC := launch(nil, rpcRequest{RequestID: idC, Path: "/c"})

// All four are registered and blocked in their pump (the handler is stuck on
// release), so all four entries are live before any cancel.
assertInflight(t, svc, idA1)
assertInflight(t, svc, idA2)
assertInflight(t, svc, idB)
assertInflight(t, svc, idC)

// Close winA: BOTH of winA's calls are swept, not just the first. Each entry
// is dropped and its canceled pump stops → run returns → done closes.
svc.cancelWindow(winA)
waitDone(t, doneA1)
waitDone(t, doneA2)
assertNotInflight(t, svc, idA1)
assertNotInflight(t, svc, idA2)

// B (other window) and C (no window) are untouched by winA's close.
assertInflight(t, svc, idB)
assertInflight(t, svc, idC)

// A nil window matches nothing: the windowless/fallback call is never swept
// by a close, and the surviving windowed call stays live.
svc.cancelWindow(nil)
assertInflight(t, svc, idB)
assertInflight(t, svc, idC)

// Release the server so B and C complete and tear down; the test exits clean.
close(release)
waitDone(t, doneB)
waitDone(t, doneC)
assertNotInflight(t, svc, idB)
assertNotInflight(t, svc, idC)
}
26 changes: 17 additions & 9 deletions go/cmd/compass-app/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import (
"github.com/sealedsecurity/compass/go/internal/appconfig"
"github.com/sealedsecurity/compass/go/internal/bridge"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/wailsapp/wails/v3/pkg/events"
)

func main() {
Expand Down Expand Up @@ -145,7 +146,7 @@ func run() error {
windowMenu := menu.AddSubmenu("Window")
windowMenu.Add("New Window").OnClick(func(_ *application.Context) {
name := nextWindowName(app)
newAppWindow(app, name, "Compass", startupJS)
newAppWindow(app, svc, name, "Compass", startupJS)
})
app.Menu.Set(menu)

Expand All @@ -155,7 +156,7 @@ func run() error {
// (record §A1/§A3), so the factory forwards the identical script to each.
names := windowNamesOrDefault(loadWindowSet(stateDir))
for _, name := range names {
newAppWindow(app, name, "Compass", startupJS)
newAppWindow(app, svc, name, "Compass", startupJS)
}

slog.Info("compass-app starting", "mode", cfg.Mode, "socket", socket, "assets", assetsDir)
Expand All @@ -182,13 +183,20 @@ func windowOptions(name, title, startupJS string) application.WebviewWindowOptio
}
}

// newAppWindow creates a Compass Bridge window on app from windowOptions. The
// returned *WebviewWindow is the frozen-record M1 seam (design §M1 interface)
// that M3b consumes to attach the per-window WindowClosing close-cancel handler;
// it is unused in the M1→M2 stack positions, so the suppression is removed when
// M3b wires the handle.
func newAppWindow(app *application.App, name, title, startupJS string) *application.WebviewWindow { //nolint:unparam // return is the M3b per-window close-handler seam (frozen record §M1); consumed there
return app.Window.NewWithOptions(windowOptions(name, title, startupJS))
// newAppWindow creates a Compass Bridge window on app from windowOptions and
// attaches the per-window WindowClosing close-cancel handler (design §M3b):
// when the window closes, every in-flight bridge call registered to it is
// canceled and dropped, driving the same teardown as compass_rpc_cancel so no
// call's pump goroutine or server-side subscription leaks for the app's
// lifetime. Attaching here (not per call site) means every window — New Window
// menu and restore loop alike — gets the leak gate; it cannot be forgotten at a
// call site. The unsubscribe func the registration returns is ignored: the
// window and its handler die together.
func newAppWindow(app *application.App, svc *bridgeService, name, title, startupJS string) {
win := app.Window.NewWithOptions(windowOptions(name, title, startupJS))
win.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) {
svc.cancelWindow(wailsWindowDispatcher{win: win})
})
}

// nextWindowName returns the first window name that has no live window on app.
Expand Down
Loading