Skip to content
Open
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
2 changes: 1 addition & 1 deletion demos/acme-commerce-lenders/docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
64 changes: 57 additions & 7 deletions demos/acme-commerce-lenders/internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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":
Expand All @@ -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":
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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()
}
Expand Down
52 changes: 52 additions & 0 deletions demos/acme-commerce-lenders/internal/cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
}
}
Loading