diff --git a/internal/modem/session.go b/internal/modem/session.go index d8192cf..ea1a1cb 100644 --- a/internal/modem/session.go +++ b/internal/modem/session.go @@ -8,6 +8,7 @@ import ( "io" "strings" "sync" + "syscall" "time" ) @@ -151,7 +152,7 @@ func (session *Session) executeLocked(ctx context.Context, command string) (Resp session.poisonLocked() return response, fmt.Errorf("write %s: %w", command, err) } - if err := session.transport.Drain(); err != nil { + if err := drainTransport(ctx, session.transport); err != nil { session.poisonLocked() return response, fmt.Errorf("drain %s: %w", command, err) } @@ -178,7 +179,7 @@ func (session *Session) executePromptLocked( session.poisonLocked() return response, fmt.Errorf("write %s: %w", command, err) } - if err := session.transport.Drain(); err != nil { + if err := drainTransport(ctx, session.transport); err != nil { session.poisonLocked() return response, fmt.Errorf("drain %s: %w", command, err) } @@ -203,7 +204,7 @@ func (session *Session) executePromptLocked( response.Duration = time.Since(started) return response, fmt.Errorf("terminate %s payload: %w", command, err) } - if err := session.transport.Drain(); err != nil { + if err := drainTransport(ctx, session.transport); err != nil { session.poisonLocked() response.Duration = time.Since(started) return response, fmt.Errorf("drain %s payload: %w", command, err) @@ -211,6 +212,21 @@ func (session *Session) executePromptLocked( return session.readFinalLocked(ctx, started, command, string(payload), response) } +// drainTransport retries tcdrain/TCSBRK when the kernel interrupts it with a +// signal. go.bug.st/serial already retries EINTR for Read, but its Linux +// Drain implementation currently returns the transient error directly. +func drainTransport(ctx context.Context, transport Transport) error { + for { + err := transport.Drain() + if !errors.Is(err, syscall.EINTR) { + return err + } + if err := ctx.Err(); err != nil { + return err + } + } +} + func (session *Session) readFinalLocked( ctx context.Context, started time.Time, diff --git a/internal/modem/session_test.go b/internal/modem/session_test.go index aec1298..480f43d 100644 --- a/internal/modem/session_test.go +++ b/internal/modem/session_test.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "sync" + "syscall" "testing" "time" ) @@ -27,6 +28,8 @@ type transcriptTransport struct { unexpected error writePartial bool writeEvents chan string + drainErrors []error + drainCount int } func (transport *transcriptTransport) Write(payload []byte) (int, error) { @@ -114,7 +117,17 @@ func (transport *transcriptTransport) Read(buffer []byte) (int, error) { return 0, nil } -func (transport *transcriptTransport) Drain() error { return nil } +func (transport *transcriptTransport) Drain() error { + transport.mu.Lock() + defer transport.mu.Unlock() + transport.drainCount++ + if len(transport.drainErrors) == 0 { + return nil + } + err := transport.drainErrors[0] + transport.drainErrors = transport.drainErrors[1:] + return err +} func (transport *transcriptTransport) ResetInputBuffer() error { transport.mu.Lock() @@ -138,6 +151,31 @@ func (transport *transcriptTransport) Close() error { return nil } +func TestSessionRetriesInterruptedDrain(t *testing.T) { + transport := &transcriptTransport{ + steps: []transportStep{{ + write: "AT+CSQ\r", + chunks: []string{"\r\nAT+CSQ\r\n+CSQ: 24,99\r\nOK\r\n"}, + }}, + drainErrors: []error{syscall.EINTR}, + } + session, err := NewSession(transport, SessionOptions{}) + if err != nil { + t.Fatalf("NewSession() error = %v", err) + } + + response, err := session.Execute(context.Background(), "AT+CSQ") + if err != nil { + t.Fatalf("Execute() error = %v", err) + } + if response.Final != "OK" { + t.Fatalf("response final = %q", response.Final) + } + if transport.drainCount != 2 { + t.Fatalf("Drain() calls = %d, want 2", transport.drainCount) + } +} + func TestSessionSeparatesInterleavedURCs(t *testing.T) { transport := &transcriptTransport{steps: []transportStep{{ write: "AT+CSQ\r",