Skip to content

Commit dd6cd05

Browse files
authored
fix: terminal input and cleanup (#47)
* fix: base64-encode terminal input before WriteSession Wails v3 serializes []byte parameters as base64 over JSON. The frontend was sending raw keystrokes which failed with "illegal base64 data". * chore: reduce terminal debug log noise - Remove raw ANSI buffer dump, log buffer size instead - Remove per-keystroke "got input" log from exec mux - Remove "past lock" breadcrumb log - Log session count instead of full session objects in ListSessions - Log only command and tty in StartSession instead of full opts/context * fix: centralize signal handling, remove dead code, fix ListSessions lock - Centralize host-process signal forwarding into a single Manager-level goroutine instead of one per terminal session. Prevents signal delivery contention with multiple terminals open. - Use RLock instead of Lock in ListSessions (read-only operation). - Remove unused cleanPTYOutput function and listenOnOut function. * fix: eliminate terminalManager race and cascade shutdown to sessions - Move terminal.NewManager() from runLocalMux goroutine into ServiceStartup so c.terminalManager is initialized before any goroutine can access it. - Derive session contexts from m.ctx instead of context.Background() so manager shutdown cascades to all active PTY sessions.
1 parent 3ccb641 commit dd6cd05

4 files changed

Lines changed: 65 additions & 73 deletions

File tree

backend/pkg/plugin/exec/controller.go

Lines changed: 14 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -95,8 +95,14 @@ type controller struct {
9595
func (c *controller) ServiceStartup(ctx context.Context, options application.ServiceOptions) error {
9696
c.app = application.Get()
9797
c.ctx = ctx
98-
go c.runMux() // plugin mux
99-
go c.runLocalMux() // local terminal should be muxed separately to avoid latency
98+
99+
// Initialize the terminal manager synchronously so c.terminalManager is
100+
// safe to read before any goroutine starts.
101+
manager, inMux, outMux, resizeMux := terminal.NewManager(ctx, c.logger)
102+
c.terminalManager = manager
103+
104+
go c.runMux() // plugin mux
105+
go c.runLocalMux(inMux, outMux, resizeMux) // local terminal should be muxed separately to avoid latency
100106
return nil
101107
}
102108

@@ -117,30 +123,17 @@ func (c *controller) safeSend(ch chan exec.StreamInput, input exec.StreamInput)
117123
return nil
118124
}
119125

120-
func listenOnOut(
121-
cancel chan struct{},
122-
source chan exec.StreamOutput,
123-
target chan exec.StreamOutput,
126+
func (c *controller) runLocalMux(
127+
inMux chan exec.StreamInput,
128+
outMux chan exec.StreamOutput,
129+
resizeMux chan exec.StreamResize,
124130
) {
125-
for {
126-
select {
127-
case <-cancel:
128-
return
129-
case output := <-source:
130-
target <- output
131-
}
132-
}
133-
}
134-
135-
func (c *controller) runLocalMux() {
136-
manager, inMux, outMux, resizeMux := terminal.NewManager(c.logger)
137-
c.terminalManager = manager
138131
for {
139132
select {
140133
case <-c.ctx.Done():
141134
return
142135
case input := <-inMux:
143-
if err := manager.WriteSession(input.SessionID, input.Data); err != nil {
136+
if err := c.terminalManager.WriteSession(input.SessionID, input.Data); err != nil {
144137
c.logger.Errorw(context.Background(), "error writing to session", "error", err)
145138
}
146139
case output := <-outMux:
@@ -181,7 +174,7 @@ func (c *controller) runLocalMux() {
181174

182175
c.app.Event.Emit(eventkey, output.Data)
183176
case resize := <-resizeMux:
184-
if err := manager.ResizeSession(resize.SessionID, resize.Rows, resize.Cols); err != nil {
177+
if err := c.terminalManager.ResizeSession(resize.SessionID, resize.Rows, resize.Cols); err != nil {
185178
c.logger.Errorw(context.Background(), "error resizing session", "error", err)
186179
}
187180
}
@@ -194,7 +187,6 @@ func (c *controller) runMux() {
194187
case <-c.ctx.Done():
195188
return
196189
case input := <-c.inputMux:
197-
c.logger.Debugw(context.Background(), "got input", "sessionID", input.SessionID, "payloadSize", len(input.Data))
198190
// Capture the channel under lock, then send outside it to avoid
199191
// holding RLock during a potentially blocking send.
200192
var ch chan exec.StreamInput

backend/pkg/terminal/manager.go

Lines changed: 48 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import (
99
"os"
1010
"os/exec"
1111
"os/signal"
12-
"regexp"
1312
"sync"
1413
"syscall"
1514
"time"
@@ -33,9 +32,11 @@ const (
3332

3433
// Manager manages terminal sessions, allowing creation, attachment, and more.
3534
type Manager struct {
35+
ctx context.Context
3636
log logging.Logger
3737
sessions map[string]*sdkexec.Session
3838
ptys map[string]*os.File
39+
cmds map[string]*exec.Cmd
3940
cancels map[string]context.CancelFunc
4041
buffers map[string]*sdkexec.OutputBuffer
4142

@@ -49,22 +50,29 @@ type Manager struct {
4950
// latency sensitive with the local manager, we're going to directly return
5051
// the channels for in and out that the exec controller will use.
5152
func NewManager(
53+
ctx context.Context,
5254
log logging.Logger,
5355
) (*Manager, chan sdkexec.StreamInput, chan sdkexec.StreamOutput, chan sdkexec.StreamResize) {
5456
inMux := make(chan sdkexec.StreamInput)
5557
outMux := make(chan sdkexec.StreamOutput)
5658
resizeMux := make(chan sdkexec.StreamResize)
5759

58-
return &Manager{
60+
mgr := &Manager{
61+
ctx: ctx,
5962
log: log.Named("TerminalManager"),
6063
sessions: make(map[string]*sdkexec.Session),
6164
ptys: make(map[string]*os.File),
65+
cmds: make(map[string]*exec.Cmd),
6266
cancels: make(map[string]context.CancelFunc),
6367
buffers: make(map[string]*sdkexec.OutputBuffer),
6468
inMux: inMux,
6569
outMux: outMux,
6670
resizeMux: resizeMux,
67-
}, inMux, outMux, resizeMux
71+
}
72+
73+
go mgr.forwardSignals()
74+
75+
return mgr, inMux, outMux, resizeMux
6876
}
6977

7078
// GetSession returns a session by its ID.
@@ -80,15 +88,15 @@ func (m *Manager) GetSession(sessionID string) (*sdkexec.Session, error) {
8088

8189
// ListSessions returns a list of details for all active sessions.
8290
func (m *Manager) ListSessions(_ *types.PluginContext) []*sdkexec.Session {
83-
m.mux.Lock()
84-
defer m.mux.Unlock()
91+
m.mux.RLock()
92+
defer m.mux.RUnlock()
8593
sessions := make([]*sdkexec.Session, 0, len(m.sessions))
8694

8795
for _, session := range m.sessions {
8896
sessions = append(sessions, session)
8997
}
9098

91-
m.log.Debugw(context.Background(), "listed sessions", "sessions", sessions)
99+
m.log.Debugw(context.Background(), "listed sessions", "count", len(sessions))
92100
return sessions
93101
}
94102

@@ -98,10 +106,10 @@ func (m *Manager) StartSession(
98106
opts sdkexec.SessionOptions,
99107
) (*sdkexec.Session, error) {
100108
logger := m.log.With(logging.Any("action", "StartSession"))
101-
logger.Debugw(context.Background(), "starting session", "options", opts, "context", pCtx)
109+
logger.Debugw(context.Background(), "starting session", "command", opts.Command, "tty", opts.TTY)
102110

103-
// Set up the command to run in a new pseudo-terminal.
104-
ctx, cancel := context.WithCancel(context.Background())
111+
// Derive from manager context so shutdown cascades to all sessions.
112+
ctx, cancel := context.WithCancel(m.ctx)
105113

106114
// determine the default shell from the commands passed in, since we may want to add flags
107115
shell := DefaultLocalShell
@@ -174,9 +182,9 @@ func (m *Manager) StartSession(
174182
}
175183

176184
m.mux.Lock()
177-
logger.Debugw(ctx, "past lock")
178185
m.sessions[opts.ID] = session
179186
m.ptys[opts.ID] = ptyFile
187+
m.cmds[opts.ID] = cmd
180188
m.cancels[opts.ID] = cancel
181189
m.buffers[opts.ID] = sdkexec.NewDefaultOutputBuffer()
182190
m.mux.Unlock()
@@ -188,7 +196,7 @@ func (m *Manager) StartSession(
188196

189197
// Start handling terminal output in a separate goroutine.
190198
go m.handleOutStream(ctx, opts.ID, ptyFile)
191-
go m.handleSignals(ctx, opts.ID, cmd)
199+
go m.handleSessionClose(ctx, opts.ID)
192200
go m.handleWaitForCompletion(ctx, opts.ID, cmd)
193201

194202
return session, nil
@@ -217,47 +225,44 @@ func (m *Manager) handleWaitForCompletion(_ context.Context, sessionID string, c
217225
m.terminateSession(sessionID)
218226
}
219227

220-
func (m *Manager) handleSignals(ctx context.Context, sessionID string, cmd *exec.Cmd) {
228+
// forwardSignals listens for host-process signals once and forwards them to
229+
// all active terminal child processes. Runs for the lifetime of the Manager.
230+
func (m *Manager) forwardSignals() {
221231
ch := make(chan os.Signal, 1)
222-
signal.Notify(ch, syscall.SIGTERM)
223-
signal.Notify(ch, syscall.SIGINT)
224-
signal.Notify(ch, syscall.SIGQUIT)
225-
226-
defer func() { signal.Stop(ch); close(ch) }()
232+
signal.Notify(ch, syscall.SIGTERM, syscall.SIGINT, syscall.SIGQUIT)
233+
defer signal.Stop(ch)
227234

228235
for {
229236
select {
230237
case sig := <-ch:
231-
switch sig {
232-
case syscall.SIGTERM:
233-
m.log.Debugw(ctx, "SIGTERM received")
234-
cmd.Process.Signal(syscall.SIGTERM)
235-
case syscall.SIGINT:
236-
m.log.Debugw(ctx, "SIGINT received")
237-
cmd.Process.Signal(syscall.SIGINT)
238-
case syscall.SIGQUIT:
239-
m.log.Debugw(ctx, "SIGQUIT received")
240-
cmd.Process.Signal(syscall.SIGQUIT)
241-
}
242-
case <-ctx.Done():
243-
m.log.Debugw(ctx,
244-
"context cancelled, stopping signal handling",
245-
"session", sessionID,
246-
)
247-
248-
// signal to ide we're done
249-
m.outMux <- sdkexec.StreamOutput{
250-
SessionID: sessionID,
251-
Target: sdkexec.StreamTargetStdOut,
252-
Data: []byte("Session terminated"),
253-
Signal: sdkexec.StreamSignalClose,
238+
m.mux.RLock()
239+
for sid, cmd := range m.cmds {
240+
if cmd.Process != nil {
241+
if err := cmd.Process.Signal(sig); err != nil {
242+
m.log.Debugw(context.Background(), "failed to forward signal",
243+
"signal", sig, "session", sid, "error", err)
244+
}
245+
}
254246
}
255-
247+
m.mux.RUnlock()
248+
case <-m.ctx.Done():
256249
return
257250
}
258251
}
259252
}
260253

254+
// handleSessionClose waits for a session's context to be cancelled and emits
255+
// a CLOSE signal to the frontend.
256+
func (m *Manager) handleSessionClose(ctx context.Context, sessionID string) {
257+
<-ctx.Done()
258+
m.outMux <- sdkexec.StreamOutput{
259+
SessionID: sessionID,
260+
Target: sdkexec.StreamTargetStdOut,
261+
Data: []byte("Session terminated"),
262+
Signal: sdkexec.StreamSignalClose,
263+
}
264+
}
265+
261266
func (m *Manager) handleOutStream(
262267
_ context.Context,
263268
sessionID string,
@@ -326,14 +331,6 @@ func (m *Manager) writeToSession(sessionID string, bytes []byte) error {
326331
return nil
327332
}
328333

329-
// cleanPTYOutput removes the `%` symbol and its associated escape sequences.
330-
func cleanPTYOutput(output string) string {
331-
// Define a regex pattern to match the escape sequence for `%`
332-
pattern := `\x1b\[1m\x1b\[7m%\x1b\[27m\x1b\[1m\x1b\[0m`
333-
re := regexp.MustCompile(pattern)
334-
return re.ReplaceAllString(output, "")
335-
}
336-
337334
// WriteSession writes data to the session's input.
338335
func (m *Manager) WriteSession(sessionID string, input []byte) error {
339336
return m.writeToSession(sessionID, input)
@@ -356,7 +353,7 @@ func (m *Manager) AttachSession(sessionID string) (*sdkexec.Session, []byte, err
356353
if buffer != nil {
357354
data = buffer.GetAll()
358355
}
359-
m.log.Debugw(context.Background(), fmt.Sprintf("session buffer data: %q", data))
356+
m.log.Debugw(context.Background(), "session buffer loaded", "session", sessionID, "bufferSize", len(data))
360357

361358
// pointer, no need to reassign
362359
session.Attached = true
@@ -422,6 +419,7 @@ func (m *Manager) terminateSessionLocked(sessionID string) {
422419
}
423420
delete(m.sessions, sessionID)
424421
delete(m.ptys, sessionID)
422+
delete(m.cmds, sessionID)
425423
delete(m.cancels, sessionID)
426424
delete(m.buffers, sessionID)
427425
m.log.Debugw(context.Background(), "session terminated", "session", sessionID)

backend/pkg/terminal/manager_windows.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
package terminal
44

55
import (
6+
"context"
67
"fmt"
78
"os"
89
"sync"
@@ -28,6 +29,7 @@ type Manager struct {
2829
}
2930

3031
func NewManager(
32+
_ context.Context,
3133
log logging.Logger,
3234
) (*Manager, chan sdkexec.StreamInput, chan sdkexec.StreamOutput, chan sdkexec.StreamResize) {
3335
inMux := make(chan sdkexec.StreamInput)

ui/providers/BottomDrawer/containers/Terminal.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -319,7 +319,7 @@ export default function TerminalContainer({ sessionId, tab }: Props) {
319319
attachedRef.current = true;
320320
fitAddon.fit()
321321
terminal.onData(data => {
322-
ExecClient.WriteSession(sessionId, data)
322+
ExecClient.WriteSession(sessionId, Base64.encode(data))
323323
.catch((err: unknown) => {
324324
log.error(new Error(parseAppError(err).detail), { event: 'write_session', sessionId });
325325
});

0 commit comments

Comments
 (0)