Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 133 additions & 18 deletions cmd/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ func startAllInOneInfra(ctx context.Context) (*infraSuite, error) {

mongoProc, mongoErr := newInfraProcess(ctx, "mongo", "mongod", mongoArgs...)
if mongoErr != nil {
return nil, fmt.Errorf("failed to start mongod: %w", mongoErr)
return nil, fmt.Errorf("start mongod: %w", mongoErr)
}

redisArgs := []string{
Expand All @@ -231,28 +231,34 @@ func startAllInOneInfra(ctx context.Context) (*infraSuite, error) {
if redisErr != nil {
mongoProc.stop()
_ = mongoProc.wait()
return nil, fmt.Errorf("failed to start redis-server: %w", redisErr)
return nil, fmt.Errorf("start redis-server: %w", redisErr)
}

suite := &infraSuite{
processes: []*infraProcess{mongoProc, redisProc},
}

// Wait for MongoDB TCP ready (or process death)
mongoAddr := net.JoinHostPort("127.0.0.1", dockerMongoPort)
if readyErr := waitForTCPReady(mongoAddr, 180*time.Second); readyErr != nil {
if err := waitForTCPOrExit(mongoAddr, 180*time.Second, mongoProc); err != nil {
suite.stop()
return nil, fmt.Errorf("mongo listener not ready: %w", readyErr)
if isIllegalInstruction(err) {
printMongoAVXError()
return nil, &MongoAVXError{Cause: err}
}
return nil, fmt.Errorf("mongodb not ready: %w", err)
}

if initErr := initReplicaSetAction(ctx, defaultMongoReplica, dockerMongoURI); initErr != nil {
suite.stop()
return nil, fmt.Errorf("failed to initialize MongoDB replica set: %w", initErr)
return nil, fmt.Errorf("init replica set: %w", initErr)
}

// Wait for Redis TCP ready (or process death)
redisAddr := net.JoinHostPort("127.0.0.1", dockerRedisPort)
if readyErr := waitForTCPReady(redisAddr, 30*time.Second); readyErr != nil {
if err := waitForTCPOrExit(redisAddr, 30*time.Second, redisProc); err != nil {
suite.stop()
return nil, fmt.Errorf("redis listener not ready: %w", readyErr)
return nil, fmt.Errorf("redis not ready: %w", err)
}

return suite, nil
Expand All @@ -265,9 +271,10 @@ func applyAllInOneDefaults(cfg *bundleConfig.Config) {
}

type infraProcess struct {
name string
cmd *exec.Cmd
done chan error
name string
cmd *exec.Cmd
done chan struct{} // Closed when process exits
exitErr error // Set when process exits
}

func newInfraProcess(ctx context.Context, name, bin string, args ...string) (*infraProcess, error) {
Expand All @@ -287,19 +294,21 @@ func newInfraProcess(ctx context.Context, name, bin string, args ...string) (*in
return nil, fmt.Errorf("failed to start %s: %w", name, startErr)
}

done := make(chan error, 1)
p := &infraProcess{
name: name,
cmd: cmd,
done: make(chan struct{}),
}

go func() {
done <- cmd.Wait()
p.exitErr = cmd.Wait()
close(p.done)
}()

go streamPipe(name, stdout)
go streamPipe(name, stderr)

return &infraProcess{
name: name,
cmd: cmd,
done: done,
}, nil
return p, nil
}

func (p *infraProcess) stop() {
Expand All @@ -323,7 +332,8 @@ func (p *infraProcess) wait() error {
return nil
}

return <-p.done
<-p.done
return p.exitErr
}

type infraSuite struct {
Expand Down Expand Up @@ -364,6 +374,50 @@ func streamPipe(name string, reader io.Reader) {
}
}

// isIllegalInstruction checks if an error indicates SIGILL.
// This typically means the CPU lacks required instructions (e.g., AVX for MongoDB 5.0+).
func isIllegalInstruction(err error) bool {
if err == nil {
return false
}
return strings.Contains(strings.ToLower(err.Error()), "illegal instruction")
}

// MongoAVXError indicates MongoDB failed due to missing AVX CPU support.
type MongoAVXError struct {
Cause error
}

func (e *MongoAVXError) Error() string {
return fmt.Sprintf("mongodb requires AVX CPU support: %v", e.Cause)
}

func (e *MongoAVXError) Unwrap() error {
return e.Cause
}

// printMongoAVXError displays a user-friendly error message for AVX failures.
func printMongoAVXError() {
const msg = `
┌─────────────────────────────────────────────────────────────────────┐
│ MongoDB failed to start: CPU does not support AVX instructions │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ MongoDB 5.0+ requires AVX CPU instructions, but your processor │
│ does not support them. The process was terminated by the kernel │
│ with SIGILL (Illegal Instruction). │
│ │
│ Solutions: │
│ • Use external MongoDB 4.4 with the start-bundle command │
│ • See compose.external.yml for example setup │
│ │
│ More info: https://github.com/grishy/any-sync-bundle/pull/39 │
│ │
└─────────────────────────────────────────────────────────────────────┘
`
fmt.Fprint(os.Stderr, msg)
}

// waitForTCPReady polls the address until a TCP connection succeeds or timeout is reached.
func waitForTCPReady(addr string, timeout time.Duration) error {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
Expand Down Expand Up @@ -406,6 +460,67 @@ func waitForTCPReady(addr string, timeout time.Duration) error {
}
}

// waitForTCPOrExit polls the address until TCP connects, process exits, or timeout.
// Returns nil if TCP is ready.
// Returns process exit error if process dies.
// Returns timeout error if deadline reached.
func waitForTCPOrExit(addr string, timeout time.Duration, proc *infraProcess) error {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()

dialer := &net.Dialer{
Timeout: 100 * time.Millisecond,
}

attempt := 0
startTime := time.Now()

for {
attempt++

// Check if process died
select {
case <-proc.done:
return proc.exitErr
default:
}

// Try TCP connect
conn, err := dialer.DialContext(ctx, "tcp", addr)
if err == nil {
_ = conn.Close()
log.Info("TCP listener ready",
zap.String("addr", addr),
zap.Int("attempts", attempt),
zap.Duration("elapsed", time.Since(startTime)))
return nil
}

// Check for timeout
select {
case <-ctx.Done():
return fmt.Errorf("timeout after %v (attempts: %d)", timeout, attempt)
default:
}

if attempt%5 == 0 {
log.Debug("waiting for TCP listener",
zap.String("addr", addr),
zap.Int("attempts", attempt),
zap.Duration("elapsed", time.Since(startTime)))
}

// Wait before retry, watching for process exit
select {
case <-proc.done:
return proc.exitErr
case <-ctx.Done():
return fmt.Errorf("timeout after %v (attempts: %d)", timeout, attempt)
case <-time.After(100 * time.Millisecond):
}
}
}

// startServices initializes and runs all bundle services using a custom two-phase approach.
//
// Why we can't use app.Start() directly:
Expand Down
138 changes: 138 additions & 0 deletions cmd/start_integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
//go:build integration

package cmd

import (
"context"
"errors"
"os"
"os/exec"
"path/filepath"
"testing"
"time"
)

// TestWaitForTCPOrExit_SIGILL tests detection of SIGILL (AVX failure simulation).
// Run with: go test -tags=integration -v ./cmd/...
func TestWaitForTCPOrExit_SIGILL(t *testing.T) {
// Create a temporary script that exits with SIGILL
tmpDir := t.TempDir()
scriptPath := filepath.Join(tmpDir, "fake-mongod")

// Script that sends SIGILL to itself
script := `#!/bin/bash
kill -ILL $$
`
if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {
t.Fatalf("failed to write script: %v", err)
}

// Start the fake mongod
ctx := context.Background()
proc, err := newInfraProcess(ctx, "mongo", scriptPath)
if err != nil {
t.Fatalf("failed to start fake mongod: %v", err)
}

// Wait for TCP (should fail with SIGILL)
err = waitForTCPOrExit("127.0.0.1:27017", 5*time.Second, proc)

// Verify we got the error
if err == nil {
t.Fatal("expected SIGILL error, got nil")
}

// Check it's detected as illegal instruction
if !isIllegalInstruction(err) {
t.Errorf("expected illegal instruction error, got: %v", err)
}

t.Logf("Correctly detected SIGILL: %v", err)
}

// TestWaitForTCPOrExit_ProcessExitsNormally tests detection of normal exit.
func TestWaitForTCPOrExit_ProcessExitsNormally(t *testing.T) {
// Create a script that exits normally with error
tmpDir := t.TempDir()
scriptPath := filepath.Join(tmpDir, "fake-mongod")

script := `#!/bin/bash
echo "Config error: invalid option"
exit 1
`
if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {
t.Fatalf("failed to write script: %v", err)
}

ctx := context.Background()
proc, err := newInfraProcess(ctx, "mongo", scriptPath)
if err != nil {
t.Fatalf("failed to start fake mongod: %v", err)
}

err = waitForTCPOrExit("127.0.0.1:27017", 5*time.Second, proc)

if err == nil {
t.Fatal("expected error, got nil")
}

// Should NOT be detected as illegal instruction
if isIllegalInstruction(err) {
t.Errorf("should not be illegal instruction: %v", err)
}

t.Logf("Correctly detected normal exit: %v", err)
}

// TestMongoAVXError_Integration tests the full error flow.
func TestMongoAVXError_Integration(t *testing.T) {
// Create a script that exits with SIGILL
tmpDir := t.TempDir()
scriptPath := filepath.Join(tmpDir, "fake-mongod")

script := `#!/bin/bash
kill -ILL $$
`
if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {
t.Fatalf("failed to write script: %v", err)
}

ctx := context.Background()
proc, err := newInfraProcess(ctx, "mongo", scriptPath)
if err != nil {
t.Fatalf("failed to start fake mongod: %v", err)
}

err = waitForTCPOrExit("127.0.0.1:27017", 5*time.Second, proc)

// Simulate the error handling from startAllInOneInfra
if err != nil && isIllegalInstruction(err) {
// This is what would happen in real code
avxErr := &MongoAVXError{Cause: err}

// Verify error chain works
var target *MongoAVXError
if !errors.As(avxErr, &target) {
t.Error("errors.As should match MongoAVXError")
}

t.Logf("Full AVX error: %v", avxErr)
} else {
t.Errorf("expected SIGILL error, got: %v", err)
}
}

// TestRealMongodNotFound tests behavior when mongod binary doesn't exist.
func TestRealMongodNotFound(t *testing.T) {
ctx := context.Background()
_, err := newInfraProcess(ctx, "mongo", "/nonexistent/mongod")

if err == nil {
t.Fatal("expected error for nonexistent binary")
}

// Should be exec error, not process exit
if !errors.Is(err, exec.ErrNotFound) && !os.IsNotExist(err) {
t.Logf("Got expected error type: %v", err)
}
}
Loading
Loading