From 19d7c2bb3ea1e6adbd359d8026a6022e58ff11d0 Mon Sep 17 00:00:00 2001 From: ruinivist Date: Sun, 22 Mar 2026 20:06:32 +0000 Subject: [PATCH] fix: handle ctrl+c based interrupts while in prompt --- internal/mire/post_process.go | 66 ++++++++++++++++++++++-- internal/mire/record_session.go | 1 + internal/mire/record_test.go | 89 +++++++++++++++++++++++++++++++++ 3 files changed, 153 insertions(+), 3 deletions(-) diff --git a/internal/mire/post_process.go b/internal/mire/post_process.go index a2df976..3a0f9c9 100644 --- a/internal/mire/post_process.go +++ b/internal/mire/post_process.go @@ -9,14 +9,18 @@ import ( var ( scriptStartPrefix = []byte("Script started on ") scriptDonePrefix = []byte("Script done on ") + shellPromptPrefix = []byte("\x1b[?2004h$ ") ) const ( - eofByte = byte(0x04) - escByte = byte(0x1b) - bellByte = byte(0x07) + interruptByte = byte(0x03) + eofByte = byte(0x04) + escByte = byte(0x1b) + bellByte = byte(0x07) ) +var interruptSuffix = []byte("^C\x1b[?2004l\r\x1b[?2004h\x1b[?2004l\r\r\n") + func loadRecordedInput(path string) ([]byte, error) { data, err := os.ReadFile(path) if err != nil { @@ -51,6 +55,62 @@ func loadRecordedOutput(path string) ([]byte, error) { return stripTerminalTitlePrefixes(data), nil } +func sanitizeInterrupts(input, output []byte) ([]byte, []byte) { + return sanitizeInterruptInput(input), sanitizeInterruptOutput(output) +} + +func sanitizeInterruptInput(data []byte) []byte { + cleaned := make([]byte, 0, len(data)) + lineStart := 0 + + for _, b := range data { + if b == interruptByte { + cleaned = cleaned[:lineStart] + continue + } + + cleaned = append(cleaned, b) + if b == '\n' { + lineStart = len(cleaned) + } + } + + return cleaned +} + +func sanitizeInterruptOutput(data []byte) []byte { + cleaned := make([]byte, 0, len(data)) + cursor := 0 + + for { + suffixStart := bytes.Index(data[cursor:], interruptSuffix) + if suffixStart == -1 { + cleaned = append(cleaned, data[cursor:]...) + return cleaned + } + suffixStart += cursor + + promptStart := bytes.LastIndex(data[cursor:suffixStart], shellPromptPrefix) + if promptStart == -1 { + end := suffixStart + len(interruptSuffix) + cleaned = append(cleaned, data[cursor:end]...) + cursor = end + continue + } + promptStart += cursor + + if bytes.IndexByte(data[promptStart+len(shellPromptPrefix):suffixStart], '\n') != -1 { + end := suffixStart + len(interruptSuffix) + cleaned = append(cleaned, data[cursor:end]...) + cursor = end + continue + } + + cleaned = append(cleaned, data[cursor:promptStart]...) + cursor = suffixStart + len(interruptSuffix) + } +} + func hasScriptWrapper(data []byte) bool { if bytes.HasPrefix(data, scriptStartPrefix) { return true diff --git a/internal/mire/record_session.go b/internal/mire/record_session.go index 3d933e2..7334b7d 100644 --- a/internal/mire/record_session.go +++ b/internal/mire/record_session.go @@ -57,6 +57,7 @@ func recordScenario(target, shellPath string, rio recordIO, sandboxConfig map[st if err != nil { return err } + recordedIn, recordedOut = sanitizeInterrupts(recordedIn, recordedOut) if err := os.WriteFile(filepath.Join(target, "in"), recordedIn, 0o644); err != nil { return err diff --git a/internal/mire/record_test.go b/internal/mire/record_test.go index 55c5c0f..0ec5a28 100644 --- a/internal/mire/record_test.go +++ b/internal/mire/record_test.go @@ -6,6 +6,7 @@ import ( "path/filepath" "strings" "testing" + "time" "mire/internal/testutil" ) @@ -315,6 +316,94 @@ func TestRecordScenarioUsesDeterministicSandbox(t *testing.T) { } } +func TestRecordScenarioStripsInterruptsFromSavedFixtures(t *testing.T) { + testutil.RequireCommands(t, "bwrap", "bash") + + root := t.TempDir() + testDir := filepath.Join(root, "e2e") + target := filepath.Join(testDir, "suite", "spec") + testutil.MustMkdirAll(t, target) + mustWriteRecordShell(t, testDir) + + err := testutil.WithWorkingDir(t, root, func() error { + reader, writer, pipeErr := os.Pipe() + if pipeErr != nil { + t.Fatalf("os.Pipe() error = %v", pipeErr) + } + t.Cleanup(func() { + _ = reader.Close() + _ = writer.Close() + }) + + errWriter := &promptSignalBuffer{ + marker: "Save recording?", + ready: make(chan struct{}), + } + + writeDone := make(chan error, 1) + go func() { + defer close(writeDone) + defer writer.Close() + + for _, chunk := range []struct { + data string + delay time.Duration + }{ + {data: "echo a\n", delay: 100 * time.Millisecond}, + {data: "echo ", delay: 100 * time.Millisecond}, + {data: "\x03", delay: 100 * time.Millisecond}, + {data: "exit\n", delay: 0}, + } { + if _, err := writer.Write([]byte(chunk.data)); err != nil { + writeDone <- err + return + } + if chunk.delay > 0 { + time.Sleep(chunk.delay) + } + } + + <-errWriter.ready + + if _, err := writer.Write([]byte("y\n")); err != nil { + writeDone <- err + return + } + + writeDone <- nil + }() + + err := recordScenario(target, recordShellPath(testDir), recordIO{ + in: reader, + out: ioDiscard{}, + err: errWriter, + }, defaultSandboxConfig(), nil, nil, nil) + if writeErr := <-writeDone; writeErr != nil { + t.Fatalf("write record input: %v", writeErr) + } + return err + }) + if err != nil { + t.Fatalf("recordScenario() error = %v", err) + } + + recordedIn := testutil.ReadFile(t, filepath.Join(target, "in")) + if recordedIn != "echo a\nexit\n" { + t.Fatalf("saved in = %q, want %q", recordedIn, "echo a\nexit\n") + } + + recordedOut := testutil.ReadFile(t, filepath.Join(target, "out")) + if strings.Contains(recordedOut, "^C") { + t.Fatalf("saved out = %q, want interrupt output removed", recordedOut) + } + if strings.Contains(recordedOut, "echo exit\r\n") { + t.Fatalf("saved out = %q, want interrupted line removed before exit", recordedOut) + } + if !strings.Contains(recordedOut, "exit\r\n") { + t.Fatalf("saved out = %q, want exit preserved", recordedOut) + } +} + func TestRecordFailsWhenRecorderShellMissing(t *testing.T) { root := t.TempDir() testDir := filepath.Join(root, "e2e")