Skip to content

Commit 52a3a2d

Browse files
committed
feat: exit host/tunnel if window not visible in taskbar
Watches the program's window state once per second during handoff new and handoff tunnel. If neither the process's own console window nor any ancestor process has a taskbar-visible window, the watcher writes a reason to the support log and exits with code 1. Falls back to walking the parent process chain so Windows Terminal and VS Code-style hosts (where the conhost is hidden by design) still work. Adds go test to CI so the suite gates PRs.
1 parent d8f04ba commit 52a3a2d

9 files changed

Lines changed: 729 additions & 0 deletions

File tree

.github/workflows/ci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ jobs:
1919
- name: go vet
2020
run: go vet ./...
2121

22+
- name: go test
23+
run: go test ./...
24+
2225
- name: go build (default)
2326
run: go build -v ./...
2427

cmd/new.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
"github.com/RealWhyKnot/Handoff/internal/dispatch"
1919
"github.com/RealWhyKnot/Handoff/internal/relay"
2020
"github.com/RealWhyKnot/Handoff/internal/supportlog"
21+
"github.com/RealWhyKnot/Handoff/internal/visibility"
2122
)
2223

2324
// Version is stamped from main.go.
@@ -73,6 +74,11 @@ func New(args []string) {
7374
cancel()
7475
}()
7576

77+
// Force-quit if the program's window stops being visible in the
78+
// taskbar -- closes the loophole where a hidden console keeps the
79+
// remote-control channel open after the host thinks it's gone.
80+
visibility.StartWatcher(ctx)
81+
7682
// Open the bridge WS.
7783
dialCtx, dialCancel := context.WithTimeout(ctx, 15*time.Second)
7884
bridge, err := relay.Dial(dialCtx, relayBase, mint.WriteToken)

cmd/tunnel.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
"time"
2323

2424
"github.com/RealWhyKnot/Handoff/internal/supportlog"
25+
"github.com/RealWhyKnot/Handoff/internal/visibility"
2526
"github.com/coder/websocket"
2627
)
2728

@@ -99,6 +100,9 @@ func Tunnel(args []string) {
99100
client := newTunnelClient(conn)
100101
defer client.shutdown("operator close")
101102

103+
// Force-quit if our window disappears from the taskbar mid-tunnel.
104+
visibility.StartWatcher(ctx)
105+
102106
go client.acceptLoop(ctx, listener)
103107
if err := client.readLoop(ctx); err != nil && !errors.Is(err, context.Canceled) {
104108
supportlog.Printf("tunnel client read loop ended: %v", err)
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
// SPDX-License-Identifier: GPL-3.0-or-later
2+
//go:build windows
3+
4+
package visibility
5+
6+
import (
7+
"os"
8+
"os/exec"
9+
"path/filepath"
10+
"strings"
11+
"syscall"
12+
"testing"
13+
"time"
14+
)
15+
16+
// TestCollectParents_IncludesSelf verifies the toolhelp snapshot syscall
17+
// plumbing. Our own PID must show up in the snapshot with a non-zero
18+
// parent.
19+
func TestCollectParents_IncludesSelf(t *testing.T) {
20+
parents, err := collectParents()
21+
if err != nil {
22+
t.Fatalf("collectParents: %v", err)
23+
}
24+
self := uint32(os.Getpid())
25+
parent, ok := parents[self]
26+
if !ok {
27+
t.Fatalf("snapshot does not contain our own PID %d", self)
28+
}
29+
if parent == 0 {
30+
t.Errorf("our parent PID is 0; expected a real PPID")
31+
}
32+
if len(parents) < 3 {
33+
t.Errorf("snapshot has %d entries; expected >= 3 on a real Windows session", len(parents))
34+
}
35+
}
36+
37+
// TestEnumWindows_PopulatesCollector verifies the EnumWindows callback
38+
// plumbing. On any normal desktop session it returns at least one
39+
// top-level window; we skip rather than fail on session-0 / headless
40+
// environments where 0 is plausible.
41+
func TestEnumWindows_PopulatesCollector(t *testing.T) {
42+
enumCollector = enumCollector[:0]
43+
enumWindowsProc.Call(enumProcCallback, 0)
44+
if len(enumCollector) == 0 {
45+
t.Skip("EnumWindows returned 0 windows; likely a headless or session-0 environment")
46+
}
47+
}
48+
49+
// TestPerformCheck_PassesInDevEnvironment confirms the full check returns
50+
// ok=true when run from an interactive session with a visible ancestor
51+
// window. Auto-skips on CI.
52+
func TestPerformCheck_PassesInDevEnvironment(t *testing.T) {
53+
if os.Getenv("CI") != "" {
54+
t.Skip("skipping live visibility check on CI")
55+
}
56+
res := performCheck()
57+
if !res.ok {
58+
t.Errorf("performCheck failed in dev environment: %s", res.reason)
59+
}
60+
}
61+
62+
// TestProbe_SurvivesWithVisibleAncestor builds the probe binary and runs
63+
// it inheriting our environment. Probe should heartbeat and exit cleanly.
64+
// Skips if the current environment has no visible ancestor at all (the
65+
// kill switch would correctly fire there).
66+
func TestProbe_SurvivesWithVisibleAncestor(t *testing.T) {
67+
if !performCheck().ok {
68+
t.Skip("no visible ancestor in this environment; can't test survival here")
69+
}
70+
probe := buildProbe(t)
71+
72+
out, err := exec.Command(probe).CombinedOutput()
73+
if err != nil {
74+
t.Fatalf("probe exited with error: %v\noutput:\n%s", err, out)
75+
}
76+
if !strings.Contains(string(out), "alive") {
77+
t.Fatalf("probe didn't print heartbeat; output:\n%s", out)
78+
}
79+
}
80+
81+
// TestProbe_KilledWhenLaunchedHiddenWithoutVisibleAncestor exercises the
82+
// kill path end-to-end. We spawn the probe with a new hidden console; the
83+
// probe's own console check fails, and its ancestor walk inherits our
84+
// process tree. If our process tree also has no visible window (headless
85+
// CI) the watcher kills the probe within a few seconds. We skip locally
86+
// where a visible terminal would shield the probe.
87+
func TestProbe_KilledWhenLaunchedHiddenWithoutVisibleAncestor(t *testing.T) {
88+
if performCheck().ok {
89+
t.Skip("visible ancestor in this environment would shield the probe via the ancestor walk. Run headless to exercise the kill path.")
90+
}
91+
probe := buildProbe(t)
92+
93+
cmd := exec.Command(probe)
94+
cmd.SysProcAttr = &syscall.SysProcAttr{
95+
HideWindow: true,
96+
CreationFlags: 0x00000010, // CREATE_NEW_CONSOLE
97+
}
98+
99+
start := time.Now()
100+
err := cmd.Run()
101+
elapsed := time.Since(start)
102+
103+
if err == nil {
104+
t.Fatal("expected probe to be killed by the watcher; it exited cleanly")
105+
}
106+
if exitErr, ok := err.(*exec.ExitError); ok {
107+
if code := exitErr.ExitCode(); code != 1 {
108+
t.Errorf("probe exit code = %d, want 1", code)
109+
}
110+
}
111+
if elapsed > 8*time.Second {
112+
t.Errorf("probe took %v to be killed; watcher should fire by ~3s", elapsed)
113+
}
114+
if elapsed < 2*time.Second {
115+
t.Errorf("probe exited too fast (%v); kill should not fire before the 2s startup grace", elapsed)
116+
}
117+
}
118+
119+
func buildProbe(t *testing.T) string {
120+
t.Helper()
121+
out := filepath.Join(t.TempDir(), "probe.exe")
122+
cmd := exec.Command("go", "build", "-o", out, "./testdata/probe")
123+
if combined, err := cmd.CombinedOutput(); err != nil {
124+
t.Fatalf("build probe: %v\n%s", err, combined)
125+
}
126+
return out
127+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
// SPDX-License-Identifier: GPL-3.0-or-later
2+
3+
// Probe is a test fixture for visibility integration tests. It starts the
4+
// real visibility watcher and prints a heartbeat for a few seconds. If the
5+
// watcher kills the process, os.Exit(1) fires from the watcher goroutine
6+
// and the test sees a non-zero exit. If the watcher stays quiet, the probe
7+
// exits 0 after the deadline.
8+
package main
9+
10+
import (
11+
"context"
12+
"fmt"
13+
"os"
14+
"time"
15+
16+
"github.com/RealWhyKnot/Handoff/internal/visibility"
17+
)
18+
19+
func main() {
20+
ctx, cancel := context.WithCancel(context.Background())
21+
defer cancel()
22+
23+
visibility.StartWatcher(ctx)
24+
25+
deadline := time.Now().Add(8 * time.Second)
26+
for time.Now().Before(deadline) {
27+
fmt.Println("alive")
28+
time.Sleep(500 * time.Millisecond)
29+
}
30+
os.Exit(0)
31+
}

internal/visibility/visibility.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
// SPDX-License-Identifier: GPL-3.0-or-later
2+
3+
// Package visibility kills the running process when no taskbar-visible
4+
// window can be found in its process ancestry. The check runs once per
5+
// second while a long-running command (host bridge or operator tunnel)
6+
// is active. On non-Windows builds StartWatcher is a no-op.
7+
package visibility
8+
9+
import (
10+
"context"
11+
"time"
12+
)
13+
14+
const (
15+
defaultTick = time.Second
16+
defaultGraceTicks = 2
17+
maxAncestorDepth = 32
18+
)
19+
20+
type checkResult struct {
21+
ok bool
22+
reason string
23+
}
24+
25+
// runWatcher drives the visibility loop. It reads ticks from the supplied
26+
// channel, skips the first graceTicks of them, then calls check on every
27+
// subsequent tick. On a failed check it calls exit and returns. A canceled
28+
// ctx returns without calling exit. Factored from StartWatcher so tests can
29+
// drive ticks deterministically.
30+
func runWatcher(
31+
ctx context.Context,
32+
ticks <-chan time.Time,
33+
graceTicks int,
34+
check func() checkResult,
35+
exit func(reason string),
36+
) {
37+
elapsed := 0
38+
for {
39+
select {
40+
case <-ctx.Done():
41+
return
42+
case _, ok := <-ticks:
43+
if !ok {
44+
return
45+
}
46+
elapsed++
47+
if elapsed <= graceTicks {
48+
continue
49+
}
50+
res := check()
51+
if !res.ok {
52+
exit(res.reason)
53+
return
54+
}
55+
}
56+
}
57+
}
58+
59+
// buildAncestors returns the set of PIDs containing self and every parent
60+
// reachable through parents. The walk stops at PID 0, PID 4 (the Windows
61+
// System process), a missing parent entry, a self-loop, or once
62+
// maxAncestorDepth steps have been taken.
63+
func buildAncestors(self uint32, parents map[uint32]uint32) map[uint32]struct{} {
64+
out := map[uint32]struct{}{}
65+
cur := self
66+
for i := 0; i < maxAncestorDepth; i++ {
67+
if cur == 0 || cur == 4 {
68+
break
69+
}
70+
if _, seen := out[cur]; seen {
71+
break
72+
}
73+
out[cur] = struct{}{}
74+
parent, ok := parents[cur]
75+
if !ok {
76+
break
77+
}
78+
cur = parent
79+
}
80+
return out
81+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
// SPDX-License-Identifier: GPL-3.0-or-later
2+
//go:build !windows
3+
4+
package visibility
5+
6+
import "context"
7+
8+
// StartWatcher is a no-op on non-Windows builds. The kill switch only
9+
// matters on Windows; this stub exists so dev builds on macOS/Linux still
10+
// compile.
11+
func StartWatcher(ctx context.Context) {
12+
_ = ctx
13+
}

0 commit comments

Comments
 (0)