diff --git a/demos/acme-commerce-lenders/docs/troubleshooting.md b/demos/acme-commerce-lenders/docs/troubleshooting.md index 020fa28..d82b5a2 100644 --- a/demos/acme-commerce-lenders/docs/troubleshooting.md +++ b/demos/acme-commerce-lenders/docs/troubleshooting.md @@ -15,7 +15,7 @@ Check first: `make kit APP=acme-commerce-lenders ARGS=ready`. It lists each app | `the world is already seeded; reset it first (run: reset)` | `seed` was run twice. It never adds to a world, so the second run stops. | Run `make kit APP=acme-commerce-lenders ARGS=reset`. | | Settling fails with `xero PUT /api.xro/2.0/Accounts: HTTP 405: Method Not Allowed` | The Xero app emulator is older than the change that lets it create accounts and bank transactions. The earlier steps work, so funding succeeds and only the booking fails. | Rebuild `twin-xero` from `WonderTwin-AI/wondertwin` `main`, restart it and press **Reset**. | | A step failed halfway, with an error and a **Retry** button | A vendor call failed, for example an app emulator stopped during funding. | Fix the cause and press **Retry**. It carries on from that step. Funding and settling are safe to run again, and cannot pay or book twice. | -| `listen tcp 127.0.0.1:8080: bind: address already in use` | Something else is on port 8080, often an earlier run of the demo. | Stop it, or serve elsewhere: `make kit APP=acme-commerce-lenders ARGS="serve --addr 127.0.0.1:8081"`. | +| `listen tcp 127.0.0.1:8080: bind: address already in use` | Something else is on port 8080 and you chose it with `--addr`. Without `--addr`, serve moves to the next free port from 8080 to 8089 and prints it. | Stop the other process, or serve elsewhere: `make kit APP=acme-commerce-lenders ARGS="serve --addr 127.0.0.1:8081"`. | | The page says "Scaffold only. The web UI has not been built yet." | The binary was built without the UI. | `make web APP=acme-commerce-lenders`, then `make kit APP=acme-commerce-lenders`. | | The inspector says "Cannot read the calls" | The demo binary stopped. | Start it again. The inspector reconnects by itself. | | The inspector is empty after the demo has run | A reset clears it, or the demo was restarted. | Run the demo again. Seed calls are never shown. | diff --git a/demos/acme-commerce-lenders/internal/cli/cli.go b/demos/acme-commerce-lenders/internal/cli/cli.go index 187f7c5..c8dde08 100644 --- a/demos/acme-commerce-lenders/internal/cli/cli.go +++ b/demos/acme-commerce-lenders/internal/cli/cli.go @@ -8,10 +8,14 @@ import ( "flag" "fmt" "io" + "net" "net/http" "os" + "os/signal" "path/filepath" + "strconv" "strings" + "syscall" "time" "github.com/localstack-samples/localstack-app-emulators/demos/acme-commerce-lenders/core" @@ -46,7 +50,7 @@ func Run(ctx context.Context, args []string, getenv func(string) string, stdout, fs := flag.NewFlagSet(cmd, flag.ContinueOnError) fs.SetOutput(stderr) fs.StringVar(&app.statePath, "state", defaultStatePath(), "file holding what the lender knows") - addr := fs.String("addr", "127.0.0.1:8080", "address the UI listens on (serve only)") + addr := fs.String("addr", defaultAddr, "address the UI listens on (serve only); when not given, the next free port is used if 8080 is taken") switch cmd { case "serve", "ready", "seed", "reset": @@ -61,10 +65,13 @@ func Run(ctx context.Context, args []string, getenv func(string) string, stdout, return 2 } + explicit := false + fs.Visit(func(f *flag.Flag) { explicit = explicit || f.Name == "addr" }) + var err error switch cmd { case "serve": - err = app.serve(*addr) + err = app.serve(*addr, explicit) case "ready": err = app.ready() case "seed": @@ -108,15 +115,55 @@ func (a *app) handler() (http.Handler, error) { return mux, nil } -func (a *app) serve(addr string) error { +const defaultAddr = "127.0.0.1:8080" + +// listen opens the UI's port. An address the presenter chose must be free or it +// fails. The default is the first free port from 8080 to 8089, so a stale run +// of the demo does not stop the next one. +func listen(addr string, explicit bool) (net.Listener, error) { + l, err := net.Listen("tcp", addr) + if err == nil || explicit { + return l, err + } + host, portStr, splitErr := net.SplitHostPort(addr) + port, convErr := strconv.Atoi(portStr) + if splitErr != nil || convErr != nil { + return nil, err + } + for p := port + 1; p < port+10; p++ { + if l, e := net.Listen("tcp", net.JoinHostPort(host, strconv.Itoa(p))); e == nil { + return l, nil + } + } + return nil, fmt.Errorf("%w (ports %d to %d are all taken; stop the earlier run, or choose one with --addr)", err, port, port+9) +} + +func (a *app) serve(addr string, explicit bool) error { h, err := a.handler() if err != nil { return err } - srv := &http.Server{Addr: addr, Handler: h, ReadHeaderTimeout: 5 * time.Second} + l, err := listen(addr, explicit) + if err != nil { + return err + } + srv := &http.Server{Handler: h, ReadHeaderTimeout: 5 * time.Second} a.printTargets("Calling:") - fmt.Fprintf(a.out, "ACME Commerce Lenders on http://%s\n", addr) - return srv.ListenAndServe() + fmt.Fprintf(a.out, "ACME Commerce Lenders on http://%s\n", l.Addr()) + + // Ctrl-C and a plain kill end the run cleanly, so the port is free at once. + ctx, stop := signal.NotifyContext(a.ctx, os.Interrupt, syscall.SIGTERM) + defer stop() + errc := make(chan error, 1) + go func() { errc <- srv.Serve(l) }() + select { + case err := <-errc: + return err + case <-ctx.Done(): + } + shutdown, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + return srv.Shutdown(shutdown) } func (a *app) printTargets(heading string) { @@ -199,7 +246,10 @@ func dollars(cents int64) string { } func defaultStatePath() string { - dir, err := os.UserCacheDir() + // The state is the lender's own record of the seeded world. Keep it in the + // user's config folder, not a cache folder the system may clear: on a Mac, + // ~/Library/Application Support rather than ~/Library/Caches. + dir, err := os.UserConfigDir() if err != nil { dir = os.TempDir() } diff --git a/demos/acme-commerce-lenders/internal/cli/cli_test.go b/demos/acme-commerce-lenders/internal/cli/cli_test.go index eff89cf..627595f 100644 --- a/demos/acme-commerce-lenders/internal/cli/cli_test.go +++ b/demos/acme-commerce-lenders/internal/cli/cli_test.go @@ -4,12 +4,14 @@ import ( "bytes" "context" "io" + "net" "net/http" "net/http/httptest" "path/filepath" "strings" "sync/atomic" "testing" + "time" "github.com/localstack-samples/localstack-app-emulators/demos/acme-commerce-lenders/internal/fakevendors" "github.com/localstack-samples/localstack-app-emulators/demos/acme-commerce-lenders/world" @@ -172,3 +174,53 @@ func TestTheServerServesTheAPIAndTheUI(t *testing.T) { t.Fatalf("the connect calls should be recorded as events: %d %s", code, body) } } + +func TestListenFallsBackOnlyWhenTheAddressWasNotChosen(t *testing.T) { + busy, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer busy.Close() + addr := busy.Addr().String() + + if _, err := listen(addr, true); err == nil { + t.Fatal("an address the presenter chose must fail when taken, not move") + } + l, err := listen(addr, false) + if err != nil { + t.Skipf("the next ports are taken on this machine: %v", err) + } + defer l.Close() + if l.Addr().String() == addr { + t.Fatalf("expected a different port, got %s", l.Addr()) + } +} + +func TestServeStopsCleanlyAndFreesThePort(t *testing.T) { + var resets atomic.Int32 + env := envFor(fakevendors.New(t, &resets).URL) + ctx, cancel := context.WithCancel(context.Background()) + var o bytes.Buffer + a := &app{ + ctx: ctx, getenv: func(k string) string { return env[k] }, out: &o, err: io.Discard, + targets: world.TargetsFromEnv(func(k string) string { return env[k] }), + statePath: filepath.Join(t.TempDir(), "state.json"), + } + done := make(chan error, 1) + go func() { done <- a.serve("127.0.0.1:0", true) }() + cancel() + select { + case err := <-done: + if err != nil { + t.Fatalf("a clean stop is not an error: %v", err) + } + case <-time.After(10 * time.Second): + t.Fatal("serve did not stop") + } +} + +func TestTheDefaultStateIsNotInACacheFolder(t *testing.T) { + if p := defaultStatePath(); strings.Contains(p, "Caches") || strings.Contains(p, "/.cache/") { + t.Fatalf("state must not live where the system may clear it: %s", p) + } +}