// Copyright (C) The Arvados Authors. All rights reserved.
//
// SPDX-License-Identifier: AGPL-3.0

package main

import (
	"bufio"
	"bytes"
	"fmt"
	"io"
	"io/ioutil"
	"log"
	"math/rand"
	"os"
	"os/exec"
	"sync"
	"syscall"
	"testing"
	"time"
)

// Test that CopyPipeToChildLog works even on lines longer than
// bufio.MaxScanTokenSize.
func TestCopyPipeToChildLogLongLines(t *testing.T) {
	logger, logBuf := bufLogger()

	pipeIn, pipeOut := io.Pipe()
	copied := make(chan bool)
	go func() {
		copyPipeToChildLog(pipeIn, logger)
		close(copied)
	}()

	sentBytes := make([]byte, bufio.MaxScanTokenSize+MaxLogLine+(1<<22))
	go func() {
		pipeOut.Write([]byte("before\n"))

		for i := range sentBytes {
			// Some bytes that aren't newlines:
			sentBytes[i] = byte((rand.Int() & 0xff) | 0x80)
		}
		sentBytes[len(sentBytes)-1] = '\n'
		pipeOut.Write(sentBytes)

		pipeOut.Write([]byte("after"))
		pipeOut.Close()
	}()

	if before, err := logBuf.ReadBytes('\n'); err != nil || string(before) != "before\n" {
		t.Fatalf("\"before\n\" not received (got \"%s\", %s)", before, err)
	}

	var receivedBytes []byte
	done := false
	for !done {
		line, err := logBuf.ReadBytes('\n')
		if err != nil {
			t.Fatal(err)
		}
		if len(line) >= 5 && string(line[0:5]) == "[...]" {
			if receivedBytes == nil {
				t.Fatal("Beginning of line reported as continuation")
			}
			line = line[5:]
		}
		if len(line) >= 6 && string(line[len(line)-6:]) == "[...]\n" {
			line = line[:len(line)-6]
		} else {
			done = true
		}
		receivedBytes = append(receivedBytes, line...)
	}
	if bytes.Compare(receivedBytes, sentBytes) != 0 {
		t.Fatalf("sent %d bytes, got %d different bytes", len(sentBytes), len(receivedBytes))
	}

	if after, err := logBuf.ReadBytes('\n'); err != nil || string(after) != "after\n" {
		t.Fatalf("\"after\n\" not received (got \"%s\", %s)", after, err)
	}

	select {
	case <-time.After(time.Second):
		t.Fatal("Timeout")
	case <-copied:
		// Done.
	}
}

func bufLogger() (*log.Logger, *bufio.Reader) {
	r, w := io.Pipe()
	logger := log.New(w, "", 0)
	return logger, bufio.NewReader(r)
}

func TestSignalOnDeadPPID(t *testing.T) {
	if !testDeadParent(t, 0) {
		t.Fatal("child should still be alive after parent dies")
	}
	if testDeadParent(t, 15) {
		t.Fatal("child should have been killed when parent died")
	}
}

// testDeadParent returns true if crunchstat's child proc is still
// alive after its parent dies.
func testDeadParent(t *testing.T, signum int) bool {
	var err error
	var bin, childlockfile, parentlockfile *os.File
	for _, f := range []**os.File{&bin, &childlockfile, &parentlockfile} {
		*f, err = ioutil.TempFile("", "crunchstat_")
		if err != nil {
			t.Fatal(err)
		}
		defer (*f).Close()
		defer os.Remove((*f).Name())
	}

	bin.Close()
	err = exec.Command("go", "build", "-o", bin.Name()).Run()
	if err != nil {
		t.Fatal(err)
	}

	err = syscall.Flock(int(parentlockfile.Fd()), syscall.LOCK_EX)
	if err != nil {
		t.Fatal(err)
	}

	cmd := exec.Command("bash", "-c", `
set -e
"$BINFILE" -cgroup-root=/none -ppid-check-interval=10ms -signal-on-dead-ppid="$SIGNUM" bash -c '
    set -e
    unlock() {
        flock --unlock "$CHILDLOCKFD"
        kill %1
    }
    trap unlock TERM
    flock --exclusive "$CHILDLOCKFD"
    echo -n "$$" > "$CHILDLOCKFILE"
    flock --unlock "$PARENTLOCKFD"
    sleep 20 </dev/null >/dev/null 2>/dev/null &
    wait %1
    unlock
' &

# wait for inner bash to start, to ensure $BINFILE has seen this bash proc as its initial PPID
flock --exclusive "$PARENTLOCKFILE" true
`)
	cmd.Env = append(os.Environ(),
		"SIGNUM="+fmt.Sprintf("%d", signum),
		"PARENTLOCKFD=3",
		"PARENTLOCKFILE="+parentlockfile.Name(),
		"CHILDLOCKFD=4",
		"CHILDLOCKFILE="+childlockfile.Name(),
		"BINFILE="+bin.Name())
	cmd.ExtraFiles = []*os.File{parentlockfile, childlockfile}
	stderr, err := cmd.StderrPipe()
	if err != nil {
		t.Fatal(err)
	}
	stdout, err := cmd.StdoutPipe()
	if err != nil {
		t.Fatal(err)
	}
	cmd.Start()
	defer cmd.Wait()

	var wg sync.WaitGroup
	wg.Add(2)
	defer wg.Wait()
	for _, rdr := range []io.ReadCloser{stderr, stdout} {
		go func(rdr io.ReadCloser) {
			defer wg.Done()
			buf := make([]byte, 1024)
			for {
				n, err := rdr.Read(buf)
				if n > 0 {
					t.Logf("%s", buf[:n])
				}
				if err != nil {
					return
				}
			}
		}(rdr)
	}

	// Wait until inner bash process releases parentlockfile
	// (which means it has locked childlockfile and written its
	// PID)
	err = exec.Command("flock", "--exclusive", parentlockfile.Name(), "true").Run()
	if err != nil {
		t.Fatal(err)
	}

	childDone := make(chan bool)
	go func() {
		// Notify the main thread when the inner bash process
		// releases its lock on childlockfile (which means
		// either its sleep process ended or it received a
		// TERM signal).
		t0 := time.Now()
		err = exec.Command("flock", "--exclusive", childlockfile.Name(), "true").Run()
		if err != nil {
			t.Fatal(err)
		}
		t.Logf("child done after %s", time.Since(t0))
		close(childDone)
	}()

	select {
	case <-time.After(500 * time.Millisecond):
		// Inner bash process is still alive after the timeout
		// period. Kill it now, so our stdout and stderr pipes
		// can finish and we don't leave a mess of child procs
		// behind.
		buf, err := ioutil.ReadFile(childlockfile.Name())
		if err != nil {
			t.Fatal(err)
		}
		var childPID int
		_, err = fmt.Sscanf(string(buf), "%d", &childPID)
		if err != nil {
			t.Fatal(err)
		}
		child, err := os.FindProcess(childPID)
		if err != nil {
			t.Fatal(err)
		}
		child.Signal(syscall.Signal(15))
		return true

	case <-childDone:
		// Inner bash process ended soon after its grandparent
		// ended.
		return false
	}
}