Skip to content

Commit d284ef8

Browse files
authored
Merge pull request #1781 from appwrite/fix/go-cli-local-function-readiness
Fix Go CLI local function readiness checks
2 parents 2addd62 + 0cc0996 commit d284ef8

2 files changed

Lines changed: 31 additions & 68 deletions

File tree

templates/go-cli/internal/docker/docker.go

Lines changed: 7 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -299,13 +299,11 @@ func (c *Client) Start(ctx context.Context, options StartOptions) (wait func() e
299299
return func() error { return <-exited }, nil
300300
}
301301

302-
// waitForPort polls until the runtime inside the container answers.
302+
// waitForPort polls until the published port accepts a connection.
303303
//
304-
// A bare TCP connect is not enough: docker's port proxy accepts connections
305-
// while the runtime behind it is still unpacking code, so `run` announced
306-
// success several seconds early. Any reply counts, including a 500 -- only a
307-
// connection that closes without one is not-ready-yet. 100 attempts at 100ms
308-
// covers a cold start without hanging a broken one forever.
304+
// Readiness must stop at the transport boundary. Sending an HTTP request here
305+
// executes the user's function before `run` has started and rejects valid
306+
// functions that do not answer that synthetic request.
309307
func waitForPort(ctx context.Context, port int) error {
310308
address := net.JoinHostPort("127.0.0.1", strconv.Itoa(port))
311309

@@ -317,8 +315,10 @@ func waitForPort(ctx context.Context, port int) error {
317315
default:
318316
}
319317

320-
err := probe(address)
318+
connection, err := net.DialTimeout("tcp", address, time.Second)
321319
if err == nil {
320+
connection.Close()
321+
322322
return nil
323323
}
324324
lastErr = err
@@ -329,34 +329,6 @@ func waitForPort(ctx context.Context, port int) error {
329329
return fmt.Errorf("timed out waiting for port %d: %w", port, lastErr)
330330
}
331331

332-
// probe sends the smallest well-formed request that gets an answer.
333-
func probe(address string) error {
334-
connection, err := net.DialTimeout("tcp", address, time.Second)
335-
if err != nil {
336-
return err
337-
}
338-
defer connection.Close()
339-
340-
if err := connection.SetDeadline(time.Now().Add(time.Second)); err != nil {
341-
return err
342-
}
343-
344-
// HTTP/1.0 so the server closes rather than holding the connection open for
345-
// a keep-alive that nothing here will use.
346-
if _, err := connection.Write([]byte("GET / HTTP/1.0\r\n\r\n")); err != nil {
347-
return err
348-
}
349-
350-
// One byte is the whole signal. Reading the status line would mean parsing
351-
// it, and the reply's CONTENT is not what is being checked.
352-
answer := make([]byte, 1)
353-
if _, err := connection.Read(answer); err != nil {
354-
return fmt.Errorf("no reply from the runtime: %w", err)
355-
}
356-
357-
return nil
358-
}
359-
360332
// PortAvailable reports whether a port can be published.
361333
//
362334
// Both loopback addresses are checked, because `localhost` is not one address: a

templates/go-cli/internal/docker/docker_test.go

Lines changed: 24 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package docker
22

33
import (
4+
"context"
45
"net"
56
"os"
67
"path/filepath"
@@ -362,53 +363,43 @@ func TestFindPortSkipsAPortInUse(t *testing.T) {
362363
}
363364
}
364365

365-
// The readiness probe requires a REPLY, not just an accept. docker's published
366-
// port is proxied, and the proxy accepts while the runtime behind it is still
367-
// unpacking code -- which is how `run` announced its URL in the middle of the
368-
// container's startup log.
369-
func TestReadinessProbeRejectsAnAcceptWithNoReply(t *testing.T) {
366+
// Readiness is a transport check, not a function invocation. A function may
367+
// validly ignore GET /, require browser headers, stream indefinitely, or have
368+
// side effects. Accepting the connection is enough to prove the published port
369+
// is reachable.
370+
func TestWaitForPortDoesNotRequireAnHTTPReply(t *testing.T) {
370371
listener, err := net.Listen("tcp", "127.0.0.1:0")
371372
if err != nil {
372373
t.Fatal(err)
373374
}
374375
defer listener.Close()
375376

376-
// Accepts and closes without answering, as a proxy with no backend does.
377-
go func() {
378-
for {
379-
connection, err := listener.Accept()
380-
if err != nil {
381-
return
382-
}
383-
connection.Close()
384-
}
385-
}()
386-
387-
if err := probe(listener.Addr().String()); err == nil {
388-
t.Error("a connection that never answered was treated as ready")
389-
}
390-
}
391-
392-
func TestReadinessProbeAcceptsAnyReply(t *testing.T) {
393-
listener, err := net.Listen("tcp", "127.0.0.1:0")
394-
if err != nil {
395-
t.Fatal(err)
396-
}
397-
defer listener.Close()
398-
399-
// 500 is what the dev server answers without the runtime secret, and it is
400-
// still proof that something is listening.
377+
accepted := make(chan struct{})
401378
go func() {
402379
connection, err := listener.Accept()
403380
if err != nil {
404381
return
405382
}
406383
defer connection.Close()
407-
_, _ = connection.Write([]byte("HTTP/1.0 500 Internal Server Error\r\n\r\n"))
384+
close(accepted)
385+
386+
// Keep the connection open without sending a reply. The old readiness
387+
// probe blocked on this until its read deadline and rejected the runtime.
388+
time.Sleep(time.Second)
408389
}()
409390

410-
if err := probe(listener.Addr().String()); err != nil {
411-
t.Errorf("a listening runtime was treated as not ready: %v", err)
391+
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
392+
defer cancel()
393+
394+
port := listener.Addr().(*net.TCPAddr).Port
395+
if err := waitForPort(ctx, port); err != nil {
396+
t.Fatalf("an accepted connection was treated as not ready: %v", err)
397+
}
398+
399+
select {
400+
case <-accepted:
401+
case <-ctx.Done():
402+
t.Fatal("readiness did not connect to the published port")
412403
}
413404
}
414405

0 commit comments

Comments
 (0)