fix: handle ctrl+c based interrupts while in prompt

This commit is contained in:
2026-03-22 20:06:32 +00:00
parent a236765060
commit 19d7c2bb3e
3 changed files with 153 additions and 3 deletions
+60
View File
@@ -9,14 +9,18 @@ import (
var (
scriptStartPrefix = []byte("Script started on ")
scriptDonePrefix = []byte("Script done on ")
shellPromptPrefix = []byte("\x1b[?2004h$ ")
)
const (
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
+1
View File
@@ -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
+89
View File
@@ -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")