Add Kotlin implementation and improve protocol documentation
This commit is contained in:
parent
34d06258c6
commit
2dcb532e24
35 changed files with 4763 additions and 14 deletions
|
|
@ -6,7 +6,10 @@
|
||||||
"Bash(sudo apt-get:*)",
|
"Bash(sudo apt-get:*)",
|
||||||
"Bash(protoc --version)",
|
"Bash(protoc --version)",
|
||||||
"Bash(dart analyze:*)",
|
"Bash(dart analyze:*)",
|
||||||
"Bash(ls *.log)"
|
"Bash(ls *.log)",
|
||||||
|
"Bash(tools/check_proto_sync.sh)",
|
||||||
|
"Bash(mv CODE_REVIEW.md code_review_0.log)",
|
||||||
|
"Bash(mv PLAN.md plan_0.log)"
|
||||||
],
|
],
|
||||||
"additionalDirectories": [
|
"additionalDirectories": [
|
||||||
"/config/workspace/toki_socket/tasks"
|
"/config/workspace/toki_socket/tasks"
|
||||||
|
|
|
||||||
10
PROTOCOL.md
10
PROTOCOL.md
|
|
@ -33,7 +33,7 @@ This protocol intentionally defines only the transport-level contract shared acr
|
||||||
- 4 bytes, big-endian positive integer. Dart reads/writes it as `int32`; Go reads/writes the same 4 bytes as `uint32`.
|
- 4 bytes, big-endian positive integer. Dart reads/writes it as `int32`; Go reads/writes the same 4 bytes as `uint32`.
|
||||||
- Value: byte length of the following `PacketBase` protobuf payload
|
- Value: byte length of the following `PacketBase` protobuf payload
|
||||||
- Value of `0`: reserved / no-op, receiver clears buffer
|
- Value of `0`: reserved / no-op, receiver clears buffer
|
||||||
- Implementations may apply safety limits to payload size. The current Dart and Go implementations reject TCP packets larger than 64 MiB.
|
- Implementations may apply safety limits to payload size. The current Dart, Go, and Kotlin implementations reject TCP packets larger than 64 MiB.
|
||||||
|
|
||||||
### WebSocket / WSS
|
### WebSocket / WSS
|
||||||
|
|
||||||
|
|
@ -111,12 +111,12 @@ On the receive side, use the same value as the registration key.
|
||||||
| Dart | `T.toString()` (equals qualified name for top-level proto messages) |
|
| Dart | `T.toString()` (equals qualified name for top-level proto messages) |
|
||||||
| Go | `string(proto.MessageName(m))` via `TypeNameOf(m)` |
|
| Go | `string(proto.MessageName(m))` via `TypeNameOf(m)` |
|
||||||
| C# | `typeof(T).Name` — verify matches proto qualified name |
|
| C# | `typeof(T).Name` — verify matches proto qualified name |
|
||||||
| Kotlin | `T::class.simpleName` — verify matches |
|
| Kotlin | `descriptorForType.fullName` via `typeNameOf(m)` |
|
||||||
| Swift | `String(describing: T.self)` — verify matches |
|
| Swift | `String(describing: T.self)` — verify matches |
|
||||||
| Python | `descriptor.name` from `MessageClass.DESCRIPTOR` — verify matches |
|
| Python | `descriptor.name` from `MessageClass.DESCRIPTOR` — verify matches |
|
||||||
| Rust | `M::default().descriptor_dyn().name().to_string()` (protobuf crate) — verify matches |
|
| Rust | `M::default().descriptor_dyn().name().to_string()` (protobuf crate) — verify matches |
|
||||||
|
|
||||||
The current packet proto has no `package` declaration, so Dart and Go both use simple names such as `TestData` and `HeartBeat`. If a future proto adds a `package`, `proto.MessageName` may become fully qualified, and all implementations must use the same value.
|
The current packet proto has no `package` declaration, so Dart, Go, and Kotlin all use simple names such as `TestData` and `HeartBeat`. If a future proto adds a `package`, `proto.MessageName` may become fully qualified, and all implementations must use the same value.
|
||||||
|
|
||||||
**Important**: Verify typeName consistency across languages before connecting heterogeneous clients.
|
**Important**: Verify typeName consistency across languages before connecting heterogeneous clients.
|
||||||
|
|
||||||
|
|
@ -177,7 +177,7 @@ Sending `HeartBeat {}`:
|
||||||
|----------|--------|------|
|
|----------|--------|------|
|
||||||
| Dart | Available | `dart/` |
|
| Dart | Available | `dart/` |
|
||||||
| C# (Unity) | Planned | `csharp/` |
|
| C# (Unity) | Planned | `csharp/` |
|
||||||
| Kotlin | Planned | `kotlin/` |
|
| Kotlin | In progress | `kotlin/` |
|
||||||
| Swift | Planned | `swift/` |
|
| Swift | Planned | `swift/` |
|
||||||
| Go | Available | `go/` |
|
| Go | Available | `go/` |
|
||||||
| Python | Planned | `python/` |
|
| Python | Planned | `python/` |
|
||||||
|
|
@ -190,4 +190,4 @@ Sending `HeartBeat {}`:
|
||||||
`dart/lib/src/packets/message_common.proto` is the canonical packet definition.
|
`dart/lib/src/packets/message_common.proto` is the canonical packet definition.
|
||||||
All language implementations must keep the same message schema and generate bindings from it.
|
All language implementations must keep the same message schema and generate bindings from it.
|
||||||
|
|
||||||
The Go implementation keeps a copy at `go/packets/message_common.proto` because Go generation needs `option go_package`. Keep the message fields in sync with the canonical Dart proto before regenerating `go/packets/message_common.pb.go`.
|
The Go implementation keeps a copy at `go/packets/message_common.proto` because Go generation needs `option go_package`. The Kotlin implementation keeps a copy at `kotlin/src/main/proto/message_common.proto` for Java/Kotlin generation options. Keep the message fields in sync with the canonical Dart proto before regenerating language bindings.
|
||||||
|
|
|
||||||
27
README.md
27
README.md
|
|
@ -36,7 +36,7 @@ Protocol compatibility is tracked separately from language package versions. See
|
||||||
|----------|--------|------|----------|
|
|----------|--------|------|----------|
|
||||||
| Dart | Available | [dart/](dart/) | Flutter, Dart server |
|
| Dart | Available | [dart/](dart/) | Flutter, Dart server |
|
||||||
| C# | Planned | `csharp/` | Unity, .NET |
|
| C# | Planned | `csharp/` | Unity, .NET |
|
||||||
| Kotlin | Planned | `kotlin/` | Android |
|
| Kotlin | In progress | [kotlin/](kotlin/) | Android, JVM |
|
||||||
| Swift | Planned | `swift/` | iOS, macOS |
|
| Swift | Planned | `swift/` | iOS, macOS |
|
||||||
| Go | Available | [go/](go/) | Server, tooling, scripting |
|
| Go | Available | [go/](go/) | Server, tooling, scripting |
|
||||||
| Python | Planned | `python/` | Server, tooling, scripting |
|
| Python | Planned | `python/` | Server, tooling, scripting |
|
||||||
|
|
@ -91,7 +91,7 @@ tools/generate_proto.sh
|
||||||
tools/check_proto_sync.sh
|
tools/check_proto_sync.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
The Go proto copy is allowed to keep only its Go-specific `option go_package` difference. `tools/check_proto_sync.sh` fails with a diff when the message schema drifts.
|
The Go and Kotlin proto copies are allowed to keep only language-specific options such as `option go_package` or Java package/class options. `tools/check_proto_sync.sh` fails with a diff when the message schema drifts.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -180,6 +180,29 @@ cd go
|
||||||
go test ./...
|
go test ./...
|
||||||
```
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd kotlin
|
||||||
|
./gradlew test
|
||||||
|
```
|
||||||
|
|
||||||
|
Cross-language checks:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd go
|
||||||
|
go run ./crosstest/go_dart.go
|
||||||
|
go run ./crosstest/go_kotlin.go
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd dart
|
||||||
|
dart run crosstest/dart_go.dart
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd kotlin
|
||||||
|
./gradlew run -PmainClass=com.tokilabs.toki_socket.crosstest.KotlinGoKt
|
||||||
|
```
|
||||||
|
|
||||||
When proto files change, also run:
|
When proto files change, also run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
//go:build ignore
|
||||||
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|
|
||||||
324
go/crosstest/go_kotlin.go
Normal file
324
go/crosstest/go_kotlin.go
Normal file
|
|
@ -0,0 +1,324 @@
|
||||||
|
//go:build ignore
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"runtime"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"google.golang.org/protobuf/proto"
|
||||||
|
"nhooyr.io/websocket"
|
||||||
|
|
||||||
|
toki "toki-labs.com/toki_socket/go"
|
||||||
|
"toki-labs.com/toki_socket/go/packets"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
host = "127.0.0.1"
|
||||||
|
goKotlinTCPPort = 29290
|
||||||
|
goKotlinWSPort = 29292
|
||||||
|
wsPath = "/"
|
||||||
|
processTimeout = 20 * time.Second
|
||||||
|
serverObservationWindow = 200 * time.Millisecond
|
||||||
|
)
|
||||||
|
|
||||||
|
func parserMap() toki.ParserMap {
|
||||||
|
return toki.ParserMap{
|
||||||
|
toki.TypeNameOf(&packets.TestData{}): func(b []byte) (proto.Message, error) {
|
||||||
|
m := &packets.TestData{}
|
||||||
|
return m, proto.Unmarshal(b, m)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
fmt.Printf("INFO typeName go=%s\n", toki.TypeNameOf(&packets.TestData{}))
|
||||||
|
if err := run(); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "FAIL crosstest error=%v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Println("PASS all go-server/kotlin-client crosstests passed")
|
||||||
|
}
|
||||||
|
|
||||||
|
func run() error {
|
||||||
|
if err := runTCPSendPush(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
time.Sleep(150 * time.Millisecond)
|
||||||
|
if err := runTCPRequests(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
time.Sleep(150 * time.Millisecond)
|
||||||
|
if err := runWSSendPush(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
time.Sleep(150 * time.Millisecond)
|
||||||
|
return runWSRequests()
|
||||||
|
}
|
||||||
|
|
||||||
|
func runTCPSendPush() error {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
received := make(chan bool, 1)
|
||||||
|
server := toki.NewTcpServer(host, goKotlinTCPPort, func(conn net.Conn) *toki.TcpClient {
|
||||||
|
return toki.NewTcpClient(conn, 0, 0, parserMap())
|
||||||
|
})
|
||||||
|
server.OnClientConnected = func(client *toki.TcpClient) {
|
||||||
|
toki.AddListenerTyped[*packets.TestData](&client.Communicator, func(data *packets.TestData) {
|
||||||
|
fmt.Printf("SERVER_RECEIVED index=%d message=%s\n", data.GetIndex(), data.GetMessage())
|
||||||
|
valid := data.GetIndex() == 101 && data.GetMessage() == "fire from kotlin client"
|
||||||
|
select {
|
||||||
|
case received <- valid:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
if valid {
|
||||||
|
_ = client.Send(&packets.TestData{Index: 200, Message: "push from go server"})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err := server.Start(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer server.Stop()
|
||||||
|
|
||||||
|
if err := runKotlinClient("tcp", goKotlinTCPPort, "send-push", map[string]bool{"1": true, "2": true}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case ok := <-received:
|
||||||
|
if !ok {
|
||||||
|
return errors.New("TCP send-push server received unexpected data")
|
||||||
|
}
|
||||||
|
case <-time.After(serverObservationWindow):
|
||||||
|
return errors.New("TCP send-push server did not receive expected data")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runTCPRequests() error {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
server := toki.NewTcpServer(host, goKotlinTCPPort, func(conn net.Conn) *toki.TcpClient {
|
||||||
|
return toki.NewTcpClient(conn, 0, 0, parserMap())
|
||||||
|
})
|
||||||
|
server.OnClientConnected = func(client *toki.TcpClient) {
|
||||||
|
toki.AddRequestListenerTyped[*packets.TestData, *packets.TestData](&client.Communicator, func(req *packets.TestData) (*packets.TestData, error) {
|
||||||
|
return &packets.TestData{
|
||||||
|
Index: req.GetIndex() * 2,
|
||||||
|
Message: "echo: " + req.GetMessage(),
|
||||||
|
}, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err := server.Start(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer server.Stop()
|
||||||
|
|
||||||
|
return runKotlinClient("tcp", goKotlinTCPPort, "requests", map[string]bool{"3": true, "4": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func runWSSendPush() error {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
received := make(chan bool, 1)
|
||||||
|
server := toki.NewWsServer(host, goKotlinWSPort, wsPath, func(conn *websocket.Conn) *toki.WsClient {
|
||||||
|
return toki.NewWsClient(conn, 0, 0, parserMap())
|
||||||
|
})
|
||||||
|
server.OnClientConnected = func(client *toki.WsClient) {
|
||||||
|
toki.AddListenerTyped[*packets.TestData](&client.Communicator, func(data *packets.TestData) {
|
||||||
|
fmt.Printf("SERVER_RECEIVED index=%d message=%s\n", data.GetIndex(), data.GetMessage())
|
||||||
|
valid := data.GetIndex() == 101 && data.GetMessage() == "fire from kotlin client"
|
||||||
|
select {
|
||||||
|
case received <- valid:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
if valid {
|
||||||
|
_ = client.Send(&packets.TestData{Index: 200, Message: "push from go server"})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err := server.Start(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer server.Stop()
|
||||||
|
|
||||||
|
if err := runKotlinClient("ws", goKotlinWSPort, "send-push", map[string]bool{"1": true, "2": true}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case ok := <-received:
|
||||||
|
if !ok {
|
||||||
|
return errors.New("WS send-push server received unexpected data")
|
||||||
|
}
|
||||||
|
case <-time.After(serverObservationWindow):
|
||||||
|
return errors.New("WS send-push server did not receive expected data")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runWSRequests() error {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
server := toki.NewWsServer(host, goKotlinWSPort, wsPath, func(conn *websocket.Conn) *toki.WsClient {
|
||||||
|
return toki.NewWsClient(conn, 0, 0, parserMap())
|
||||||
|
})
|
||||||
|
server.OnClientConnected = func(client *toki.WsClient) {
|
||||||
|
toki.AddRequestListenerTyped[*packets.TestData, *packets.TestData](&client.Communicator, func(req *packets.TestData) (*packets.TestData, error) {
|
||||||
|
return &packets.TestData{
|
||||||
|
Index: req.GetIndex() * 2,
|
||||||
|
Message: "echo: " + req.GetMessage(),
|
||||||
|
}, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err := server.Start(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer server.Stop()
|
||||||
|
|
||||||
|
return runKotlinClient("ws", goKotlinWSPort, "requests", map[string]bool{"3": true, "4": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func runKotlinClient(mode string, port int, phase string, expected map[string]bool) error {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), processTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
kotlinDir, err := kotlinPackageDir()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, "./gradlew", "run",
|
||||||
|
"--args=--mode="+mode+" --port="+fmt.Sprint(port)+" --phase="+phase,
|
||||||
|
)
|
||||||
|
cmd.Dir = kotlinDir
|
||||||
|
|
||||||
|
stdoutPipe, err := cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
stderrPipe, err := cmd.StderrPipe()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var mu sync.Mutex
|
||||||
|
var resultLines []string
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(2)
|
||||||
|
go scanLines(&wg, stdoutPipe, os.Stdout, &mu, &resultLines)
|
||||||
|
go scanLines(&wg, stderrPipe, os.Stderr, nil, nil)
|
||||||
|
|
||||||
|
waitErr := cmd.Wait()
|
||||||
|
wg.Wait()
|
||||||
|
if ctx.Err() == context.DeadlineExceeded {
|
||||||
|
return fmt.Errorf("kotlin client %s/%s timed out", mode, phase)
|
||||||
|
}
|
||||||
|
return validateResultLines("kotlin-client "+mode+"/"+phase, waitErr, resultLines, expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
func kotlinPackageDir() (string, error) {
|
||||||
|
candidates := make([]string, 0, 3)
|
||||||
|
|
||||||
|
_, filename, _, ok := runtime.Caller(0)
|
||||||
|
if ok {
|
||||||
|
repoRoot := filepath.Dir(filepath.Dir(filepath.Dir(filename)))
|
||||||
|
candidates = append(candidates, filepath.Join(repoRoot, "kotlin"))
|
||||||
|
}
|
||||||
|
if wd, err := os.Getwd(); err == nil {
|
||||||
|
candidates = append(candidates, findKotlinPackageCandidates(wd)...)
|
||||||
|
}
|
||||||
|
if executable, err := os.Executable(); err == nil {
|
||||||
|
candidates = append(candidates, findKotlinPackageCandidates(filepath.Dir(executable))...)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, candidate := range candidates {
|
||||||
|
if isKotlinPackageDir(candidate) {
|
||||||
|
return candidate, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("cannot resolve kotlin package directory from candidates %v", candidates)
|
||||||
|
}
|
||||||
|
|
||||||
|
func findKotlinPackageCandidates(start string) []string {
|
||||||
|
candidates := make([]string, 0)
|
||||||
|
for dir := start; ; dir = filepath.Dir(dir) {
|
||||||
|
candidates = append(candidates, filepath.Join(dir, "kotlin"))
|
||||||
|
if filepath.Base(dir) == "kotlin" {
|
||||||
|
candidates = append(candidates, dir)
|
||||||
|
}
|
||||||
|
parent := filepath.Dir(dir)
|
||||||
|
if parent == dir {
|
||||||
|
return candidates
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isKotlinPackageDir(dir string) bool {
|
||||||
|
info, err := os.Stat(filepath.Join(dir, "build.gradle.kts"))
|
||||||
|
return err == nil && !info.IsDir()
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanLines(wg *sync.WaitGroup, reader io.Reader, writer *os.File, mu *sync.Mutex, resultLines *[]string) {
|
||||||
|
defer wg.Done()
|
||||||
|
scanner := bufio.NewScanner(reader)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
fmt.Fprintln(writer, line)
|
||||||
|
if mu != nil && startsWithResult(line) {
|
||||||
|
mu.Lock()
|
||||||
|
*resultLines = append(*resultLines, line)
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateResultLines(label string, waitErr error, lines []string, expected map[string]bool) error {
|
||||||
|
failed := make([]string, 0)
|
||||||
|
passed := make(map[string]bool)
|
||||||
|
re := regexp.MustCompile(`scenario=([^ ]+)`)
|
||||||
|
for _, line := range lines {
|
||||||
|
if len(line) >= 5 && line[:5] == "FAIL " {
|
||||||
|
failed = append(failed, line)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(line) >= 5 && line[:5] == "PASS " {
|
||||||
|
match := re.FindStringSubmatch(line)
|
||||||
|
if len(match) == 2 {
|
||||||
|
passed[match[1]] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
missing := make([]string, 0)
|
||||||
|
for scenario := range expected {
|
||||||
|
if !passed[scenario] {
|
||||||
|
missing = append(missing, scenario)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if waitErr != nil || len(failed) > 0 || len(missing) > 0 {
|
||||||
|
return fmt.Errorf("%s failed waitErr=%v failed=%v missing=%v", label, waitErr, failed, missing)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func startsWithResult(line string) bool {
|
||||||
|
return (len(line) >= 5 && line[:5] == "PASS ") || (len(line) >= 5 && line[:5] == "FAIL ")
|
||||||
|
}
|
||||||
205
go/crosstest/kotlin_go_client/main.go
Normal file
205
go/crosstest/kotlin_go_client/main.go
Normal file
|
|
@ -0,0 +1,205 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"google.golang.org/protobuf/proto"
|
||||||
|
|
||||||
|
toki "toki-labs.com/toki_socket/go"
|
||||||
|
"toki-labs.com/toki_socket/go/packets"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
host = "127.0.0.1"
|
||||||
|
wsPath = "/"
|
||||||
|
connectWindow = 3 * time.Second
|
||||||
|
requestWindow = 2 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
type clientHandle struct {
|
||||||
|
communicator *toki.Communicator
|
||||||
|
send func(proto.Message) error
|
||||||
|
close func() error
|
||||||
|
}
|
||||||
|
|
||||||
|
func parserMap() toki.ParserMap {
|
||||||
|
return toki.ParserMap{
|
||||||
|
toki.TypeNameOf(&packets.TestData{}): func(b []byte) (proto.Message, error) {
|
||||||
|
m := &packets.TestData{}
|
||||||
|
return m, proto.Unmarshal(b, m)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
mode := flag.String("mode", "tcp", "transport mode: tcp or ws")
|
||||||
|
port := flag.Int("port", 0, "server port")
|
||||||
|
phase := flag.String("phase", "send-push", "test phase: send-push or requests")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
fmt.Printf("INFO typeName go=%s\n", toki.TypeNameOf(&packets.TestData{}))
|
||||||
|
|
||||||
|
if *port == 0 {
|
||||||
|
fail("setup", "port is required")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := dialWithRetry(*mode, *port)
|
||||||
|
if err != nil {
|
||||||
|
fail("setup", err.Error())
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer client.close()
|
||||||
|
|
||||||
|
var ok bool
|
||||||
|
switch *phase {
|
||||||
|
case "send-push":
|
||||||
|
ok = runSendPush(client)
|
||||||
|
case "requests":
|
||||||
|
ok = runRequests(client)
|
||||||
|
default:
|
||||||
|
fail("setup", fmt.Sprintf("unknown phase %q", *phase))
|
||||||
|
ok = false
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func dialWithRetry(mode string, port int) (*clientHandle, error) {
|
||||||
|
deadline := time.Now().Add(connectWindow)
|
||||||
|
var lastErr error
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
|
||||||
|
handle, err := dial(ctx, mode, port)
|
||||||
|
cancel()
|
||||||
|
if err == nil {
|
||||||
|
return handle, nil
|
||||||
|
}
|
||||||
|
lastErr = err
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("connect %s:%d timed out: %w", mode, port, lastErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func dial(ctx context.Context, mode string, port int) (*clientHandle, error) {
|
||||||
|
switch mode {
|
||||||
|
case "tcp":
|
||||||
|
client, err := toki.DialTcp(ctx, host, port, 0, 0, parserMap())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &clientHandle{
|
||||||
|
communicator: &client.Communicator,
|
||||||
|
send: client.Send,
|
||||||
|
close: client.Close,
|
||||||
|
}, nil
|
||||||
|
case "ws":
|
||||||
|
client, err := toki.DialWsWithHeartbeat(ctx, host, port, wsPath, 0, 0, parserMap())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &clientHandle{
|
||||||
|
communicator: &client.Communicator,
|
||||||
|
send: client.Send,
|
||||||
|
close: client.Close,
|
||||||
|
}, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unknown mode %q", mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runSendPush(client *clientHandle) bool {
|
||||||
|
pushCh := make(chan *packets.TestData, 1)
|
||||||
|
toki.AddListenerTyped[*packets.TestData](client.communicator, func(msg *packets.TestData) {
|
||||||
|
pushCh <- msg
|
||||||
|
})
|
||||||
|
|
||||||
|
err := client.send(&packets.TestData{
|
||||||
|
Index: 101,
|
||||||
|
Message: "fire from go client",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
fail("1", err.Error())
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
pass("1", "fire-and-forget sent")
|
||||||
|
|
||||||
|
select {
|
||||||
|
case msg := <-pushCh:
|
||||||
|
if msg.GetIndex() != 200 || msg.GetMessage() != "push from kotlin server" {
|
||||||
|
fail("2", fmt.Sprintf("unexpected push index=%d message=%q", msg.GetIndex(), msg.GetMessage()))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
pass("2", "received push from kotlin server")
|
||||||
|
return true
|
||||||
|
case <-time.After(requestWindow):
|
||||||
|
fail("2", "timeout waiting for server push")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runRequests(client *clientHandle) bool {
|
||||||
|
res, err := toki.SendRequestTyped[*packets.TestData, *packets.TestData](
|
||||||
|
client.communicator,
|
||||||
|
&packets.TestData{Index: 21, Message: "single request from go"},
|
||||||
|
requestWindow,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
fail("3", err.Error())
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if res.GetIndex() != 42 || res.GetMessage() != "echo: single request from go" {
|
||||||
|
fail("3", fmt.Sprintf("unexpected response index=%d message=%q", res.GetIndex(), res.GetMessage()))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
pass("3", "single request response matched")
|
||||||
|
|
||||||
|
const count = 5
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
errCh := make(chan error, count)
|
||||||
|
for i := 0; i < count; i++ {
|
||||||
|
i := i
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
index := int32(30 + i)
|
||||||
|
message := fmt.Sprintf("multi request %d from go", i)
|
||||||
|
res, err := toki.SendRequestTyped[*packets.TestData, *packets.TestData](
|
||||||
|
client.communicator,
|
||||||
|
&packets.TestData{Index: index, Message: message},
|
||||||
|
requestWindow,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
errCh <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if res.GetIndex() != index*2 || res.GetMessage() != "echo: "+message {
|
||||||
|
errCh <- fmt.Errorf("request %d got index=%d message=%q", i, res.GetIndex(), res.GetMessage())
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
close(errCh)
|
||||||
|
for err := range errCh {
|
||||||
|
if err != nil {
|
||||||
|
fail("4", err.Error())
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pass("4", "concurrent request responses matched")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func pass(scenario, detail string) {
|
||||||
|
fmt.Printf("PASS scenario=%s detail=%s\n", scenario, detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fail(scenario, detail string) {
|
||||||
|
fmt.Printf("FAIL scenario=%s error=%s\n", scenario, detail)
|
||||||
|
}
|
||||||
64
kotlin/build.gradle.kts
Normal file
64
kotlin/build.gradle.kts
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
plugins {
|
||||||
|
kotlin("jvm") version "2.0.0"
|
||||||
|
application
|
||||||
|
id("com.google.protobuf") version "0.9.4"
|
||||||
|
}
|
||||||
|
|
||||||
|
group = "com.tokilabs"
|
||||||
|
version = "0.1.0"
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
jvmToolchain(17)
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceSets {
|
||||||
|
create("crosstest") {
|
||||||
|
kotlin.srcDir("crosstest")
|
||||||
|
compileClasspath += sourceSets["main"].output + configurations["runtimeClasspath"]
|
||||||
|
runtimeClasspath += output + compileClasspath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1")
|
||||||
|
implementation("com.google.protobuf:protobuf-kotlin:4.27.0")
|
||||||
|
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||||
|
implementation("org.java-websocket:Java-WebSocket:1.5.6")
|
||||||
|
|
||||||
|
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1")
|
||||||
|
testImplementation(kotlin("test"))
|
||||||
|
}
|
||||||
|
|
||||||
|
protobuf {
|
||||||
|
protoc {
|
||||||
|
artifact = "com.google.protobuf:protoc:4.27.0"
|
||||||
|
}
|
||||||
|
generateProtoTasks {
|
||||||
|
all().forEach { task ->
|
||||||
|
task.builtins {
|
||||||
|
create("kotlin")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
application {
|
||||||
|
mainClass.set(
|
||||||
|
(findProperty("mainClass") as String?)
|
||||||
|
?: "com.tokilabs.toki_socket.crosstest.MainKt",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val crosstestSourceSet = sourceSets["crosstest"]
|
||||||
|
|
||||||
|
tasks.named<JavaExec>("run") {
|
||||||
|
classpath = crosstestSourceSet.runtimeClasspath
|
||||||
|
mainClass.set(
|
||||||
|
(findProperty("mainClass") as String?)
|
||||||
|
?: "com.tokilabs.toki_socket.crosstest.MainKt",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.test {
|
||||||
|
useJUnitPlatform()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,208 @@
|
||||||
|
package com.tokilabs.toki_socket.crosstest
|
||||||
|
|
||||||
|
import com.tokilabs.toki_socket.DialTcp
|
||||||
|
import com.tokilabs.toki_socket.DialWs
|
||||||
|
import com.tokilabs.toki_socket.ParserMap
|
||||||
|
import com.tokilabs.toki_socket.WsClient
|
||||||
|
import com.tokilabs.toki_socket.TcpClient
|
||||||
|
import com.tokilabs.toki_socket.addListenerTyped
|
||||||
|
import com.tokilabs.toki_socket.sendRequestTyped
|
||||||
|
import com.tokilabs.toki_socket.typeNameOf
|
||||||
|
import com.tokilabs.toki_socket.packets.TestData
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.coroutines.withTimeout
|
||||||
|
import kotlin.system.exitProcess
|
||||||
|
|
||||||
|
private const val HOST = "127.0.0.1"
|
||||||
|
private const val WS_PATH = "/"
|
||||||
|
private const val CONNECT_WINDOW_MS = 3_000L
|
||||||
|
private const val REQUEST_WINDOW_MS = 2_000L
|
||||||
|
|
||||||
|
private interface ClientHandle {
|
||||||
|
suspend fun send(data: TestData)
|
||||||
|
|
||||||
|
suspend fun close()
|
||||||
|
|
||||||
|
val communicator: com.tokilabs.toki_socket.Communicator
|
||||||
|
}
|
||||||
|
|
||||||
|
private class TcpHandle(
|
||||||
|
private val client: TcpClient,
|
||||||
|
) : ClientHandle {
|
||||||
|
override val communicator = client.communicator
|
||||||
|
|
||||||
|
override suspend fun send(data: TestData) {
|
||||||
|
client.send(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun close() {
|
||||||
|
client.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class WsHandle(
|
||||||
|
private val client: WsClient,
|
||||||
|
) : ClientHandle {
|
||||||
|
override val communicator = client.communicator
|
||||||
|
|
||||||
|
override suspend fun send(data: TestData) {
|
||||||
|
client.send(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun close() {
|
||||||
|
client.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun main(args: Array<String>) = runBlocking {
|
||||||
|
val mode = argValue(args, "mode") ?: "tcp"
|
||||||
|
val phase = argValue(args, "phase") ?: "send-push"
|
||||||
|
val port = argValue(args, "port")?.toIntOrNull()
|
||||||
|
|
||||||
|
println("INFO typeName kotlin=${typeNameOf<TestData>()}")
|
||||||
|
|
||||||
|
if (port == null) {
|
||||||
|
fail("setup", "port is required")
|
||||||
|
exitProcess(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
val client = try {
|
||||||
|
connectWithRetry(mode, port)
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
fail("setup", error.message ?: error.toString())
|
||||||
|
exitProcess(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
val ok = try {
|
||||||
|
when (phase) {
|
||||||
|
"send-push" -> runSendPush(client)
|
||||||
|
"requests" -> runRequests(client)
|
||||||
|
else -> {
|
||||||
|
fail("setup", "unknown phase $phase")
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
client.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ok) exitProcess(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun connectWithRetry(mode: String, port: Int): ClientHandle {
|
||||||
|
val deadline = System.nanoTime() + CONNECT_WINDOW_MS * 1_000_000L
|
||||||
|
var lastError: Throwable? = null
|
||||||
|
while (System.nanoTime() < deadline) {
|
||||||
|
try {
|
||||||
|
return when (mode) {
|
||||||
|
"tcp" -> TcpHandle(DialTcp(HOST, port, 0, 0, parserMap()))
|
||||||
|
"ws" -> WsHandle(DialWs(HOST, port, WS_PATH, 0, 0, parserMap()))
|
||||||
|
else -> error("unknown mode $mode")
|
||||||
|
}
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
lastError = error
|
||||||
|
delay(100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
error("connect $mode:$port timed out: $lastError")
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun runSendPush(client: ClientHandle): Boolean {
|
||||||
|
val push = CompletableDeferred<TestData>()
|
||||||
|
addListenerTyped<TestData>(client.communicator) {
|
||||||
|
push.complete(it)
|
||||||
|
}
|
||||||
|
|
||||||
|
return try {
|
||||||
|
client.send(
|
||||||
|
TestData.newBuilder()
|
||||||
|
.setIndex(101)
|
||||||
|
.setMessage("fire from kotlin client")
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
pass("1", "fire-and-forget sent")
|
||||||
|
|
||||||
|
val msg = withTimeout(REQUEST_WINDOW_MS) { push.await() }
|
||||||
|
if (msg.index != 200 || msg.message != "push from go server") {
|
||||||
|
fail("2", "unexpected push index=${msg.index} message=${msg.message}")
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
pass("2", "received push from go server")
|
||||||
|
true
|
||||||
|
}
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
fail("2", error.message ?: error.toString())
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun runRequests(client: ClientHandle): Boolean =
|
||||||
|
try {
|
||||||
|
val single = sendRequestTyped<TestData, TestData>(
|
||||||
|
client.communicator,
|
||||||
|
TestData.newBuilder()
|
||||||
|
.setIndex(21)
|
||||||
|
.setMessage("single request from kotlin")
|
||||||
|
.build(),
|
||||||
|
REQUEST_WINDOW_MS,
|
||||||
|
)
|
||||||
|
if (single.index != 42 || single.message != "echo: single request from kotlin") {
|
||||||
|
fail("3", "unexpected response index=${single.index} message=${single.message}")
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
pass("3", "single request response matched")
|
||||||
|
runConcurrentRequests(client)
|
||||||
|
}
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
fail("3", error.message ?: error.toString())
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun runConcurrentRequests(client: ClientHandle): Boolean =
|
||||||
|
coroutineScope {
|
||||||
|
val jobs = (0 until 5).map { i ->
|
||||||
|
async {
|
||||||
|
val index = 30 + i
|
||||||
|
val message = "multi request $i from kotlin"
|
||||||
|
val res = sendRequestTyped<TestData, TestData>(
|
||||||
|
client.communicator,
|
||||||
|
TestData.newBuilder()
|
||||||
|
.setIndex(index)
|
||||||
|
.setMessage(message)
|
||||||
|
.build(),
|
||||||
|
REQUEST_WINDOW_MS,
|
||||||
|
)
|
||||||
|
check(res.index == index * 2 && res.message == "echo: $message") {
|
||||||
|
"request $i got index=${res.index} message=${res.message}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
jobs.forEach { it.await() }
|
||||||
|
pass("4", "concurrent request responses matched")
|
||||||
|
true
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
fail("4", error.message ?: error.toString())
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parserMap(): ParserMap =
|
||||||
|
mapOf(typeNameOf<TestData>() to { TestData.parseFrom(it) })
|
||||||
|
|
||||||
|
private fun argValue(args: Array<String>, name: String): String? {
|
||||||
|
val prefix = "--$name="
|
||||||
|
return args.firstOrNull { it.startsWith(prefix) }?.substring(prefix.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun pass(scenario: String, detail: String) {
|
||||||
|
println("PASS scenario=$scenario detail=$detail")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fail(scenario: String, error: String) {
|
||||||
|
println("FAIL scenario=$scenario error=$error")
|
||||||
|
}
|
||||||
233
kotlin/crosstest/kotlin_go.kt
Normal file
233
kotlin/crosstest/kotlin_go.kt
Normal file
|
|
@ -0,0 +1,233 @@
|
||||||
|
@file:JvmName("KotlinGoKt")
|
||||||
|
|
||||||
|
package com.tokilabs.toki_socket.crosstest
|
||||||
|
|
||||||
|
import com.tokilabs.toki_socket.TcpClient
|
||||||
|
import com.tokilabs.toki_socket.TcpServer
|
||||||
|
import com.tokilabs.toki_socket.ParserMap
|
||||||
|
import com.tokilabs.toki_socket.WsClient
|
||||||
|
import com.tokilabs.toki_socket.WsServer
|
||||||
|
import com.tokilabs.toki_socket.addListenerTyped
|
||||||
|
import com.tokilabs.toki_socket.addRequestListenerTyped
|
||||||
|
import com.tokilabs.toki_socket.typeNameOf
|
||||||
|
import com.tokilabs.toki_socket.packets.TestData
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
import java.io.File
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import kotlin.system.exitProcess
|
||||||
|
|
||||||
|
private const val HOST = "127.0.0.1"
|
||||||
|
private const val TCP_PORT = 29390
|
||||||
|
private const val WS_PORT = 29392
|
||||||
|
private const val WS_PATH = "/"
|
||||||
|
private const val PROCESS_TIMEOUT_MS = 20_000L
|
||||||
|
private const val SERVER_OBSERVATION_MS = 200L
|
||||||
|
|
||||||
|
fun main() = runBlocking {
|
||||||
|
println("INFO typeName kotlin=${typeNameOf<TestData>()}")
|
||||||
|
try {
|
||||||
|
runTcpSendPush()
|
||||||
|
delay(150)
|
||||||
|
runTcpRequests()
|
||||||
|
delay(150)
|
||||||
|
runWsSendPush()
|
||||||
|
delay(150)
|
||||||
|
runWsRequests()
|
||||||
|
println("PASS all kotlin-server/go-client crosstests passed")
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
System.err.println("FAIL crosstest error=${error.message ?: error}")
|
||||||
|
exitProcess(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun runTcpSendPush() = coroutineScope {
|
||||||
|
val received = CompletableDeferred<Boolean>()
|
||||||
|
val server = TcpServer(HOST, TCP_PORT) { socket ->
|
||||||
|
TcpClient.fromSocket(socket, 0, 0, parserMap())
|
||||||
|
}
|
||||||
|
server.onClientConnected = { client ->
|
||||||
|
addListenerTyped<TestData>(client.communicator) { data ->
|
||||||
|
println("SERVER_RECEIVED index=${data.index} message=${data.message}")
|
||||||
|
val valid = data.index == 101 && data.message == "fire from go client"
|
||||||
|
received.complete(valid)
|
||||||
|
if (valid) {
|
||||||
|
launch {
|
||||||
|
client.send(
|
||||||
|
TestData.newBuilder()
|
||||||
|
.setIndex(200)
|
||||||
|
.setMessage("push from kotlin server")
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
withServer(server::start, server::stop) {
|
||||||
|
runGoClient("tcp", TCP_PORT, "send-push", setOf("1", "2"))
|
||||||
|
val ok = withTimeoutOrNull(SERVER_OBSERVATION_MS) { received.await() } ?: false
|
||||||
|
check(ok) { "TCP send-push server did not receive expected data" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun runTcpRequests() {
|
||||||
|
val server = TcpServer(HOST, TCP_PORT) { socket ->
|
||||||
|
TcpClient.fromSocket(socket, 0, 0, parserMap())
|
||||||
|
}
|
||||||
|
server.onClientConnected = { client ->
|
||||||
|
addRequestListenerTyped<TestData, TestData>(client.communicator) { req ->
|
||||||
|
TestData.newBuilder()
|
||||||
|
.setIndex(req.index * 2)
|
||||||
|
.setMessage("echo: ${req.message}")
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
withServer(server::start, server::stop) {
|
||||||
|
runGoClient("tcp", TCP_PORT, "requests", setOf("3", "4"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun runWsSendPush() = coroutineScope {
|
||||||
|
val received = CompletableDeferred<Boolean>()
|
||||||
|
val server = WsServer(HOST, WS_PORT, WS_PATH) { conn ->
|
||||||
|
WsClient.forServer(conn, 0, 0, parserMap())
|
||||||
|
}
|
||||||
|
server.onClientConnected = { client ->
|
||||||
|
addListenerTyped<TestData>(client.communicator) { data ->
|
||||||
|
println("SERVER_RECEIVED index=${data.index} message=${data.message}")
|
||||||
|
val valid = data.index == 101 && data.message == "fire from go client"
|
||||||
|
received.complete(valid)
|
||||||
|
if (valid) {
|
||||||
|
launch {
|
||||||
|
client.send(
|
||||||
|
TestData.newBuilder()
|
||||||
|
.setIndex(200)
|
||||||
|
.setMessage("push from kotlin server")
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
withServer(server::start, server::stop) {
|
||||||
|
runGoClient("ws", WS_PORT, "send-push", setOf("1", "2"))
|
||||||
|
val ok = withTimeoutOrNull(SERVER_OBSERVATION_MS) { received.await() } ?: false
|
||||||
|
check(ok) { "WS send-push server did not receive expected data" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun runWsRequests() {
|
||||||
|
val server = WsServer(HOST, WS_PORT, WS_PATH) { conn ->
|
||||||
|
WsClient.forServer(conn, 0, 0, parserMap())
|
||||||
|
}
|
||||||
|
server.onClientConnected = { client ->
|
||||||
|
addRequestListenerTyped<TestData, TestData>(client.communicator) { req ->
|
||||||
|
TestData.newBuilder()
|
||||||
|
.setIndex(req.index * 2)
|
||||||
|
.setMessage("echo: ${req.message}")
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
withServer(server::start, server::stop) {
|
||||||
|
runGoClient("ws", WS_PORT, "requests", setOf("3", "4"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private inline fun withServer(
|
||||||
|
start: () -> Unit,
|
||||||
|
stop: () -> Unit,
|
||||||
|
body: () -> Unit,
|
||||||
|
) {
|
||||||
|
start()
|
||||||
|
try {
|
||||||
|
body()
|
||||||
|
} finally {
|
||||||
|
stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun runGoClient(
|
||||||
|
mode: String,
|
||||||
|
port: Int,
|
||||||
|
phase: String,
|
||||||
|
expectedScenarios: Set<String>,
|
||||||
|
) {
|
||||||
|
val process = ProcessBuilder(
|
||||||
|
"go",
|
||||||
|
"run",
|
||||||
|
"./crosstest/kotlin_go_client",
|
||||||
|
"--mode=$mode",
|
||||||
|
"--port=$port",
|
||||||
|
"--phase=$phase",
|
||||||
|
)
|
||||||
|
.directory(goDir())
|
||||||
|
.redirectErrorStream(false)
|
||||||
|
.start()
|
||||||
|
|
||||||
|
val stdoutLines = mutableListOf<String>()
|
||||||
|
val stdoutThread = Thread {
|
||||||
|
process.inputStream.bufferedReader().forEachLine { line ->
|
||||||
|
println(line)
|
||||||
|
if (line.startsWith("PASS ") || line.startsWith("FAIL ")) {
|
||||||
|
stdoutLines.add(line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val stderrThread = Thread {
|
||||||
|
process.errorStream.bufferedReader().forEachLine { line ->
|
||||||
|
System.err.println(line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stdoutThread.start()
|
||||||
|
stderrThread.start()
|
||||||
|
|
||||||
|
val finished = process.waitFor(PROCESS_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||||
|
if (!finished) {
|
||||||
|
process.destroyForcibly()
|
||||||
|
}
|
||||||
|
stdoutThread.join()
|
||||||
|
stderrThread.join()
|
||||||
|
|
||||||
|
validateResultLines(
|
||||||
|
"go-client $mode/$phase",
|
||||||
|
if (finished) process.exitValue() else -1,
|
||||||
|
stdoutLines,
|
||||||
|
expectedScenarios,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun validateResultLines(
|
||||||
|
label: String,
|
||||||
|
exitCode: Int,
|
||||||
|
lines: List<String>,
|
||||||
|
expectedScenarios: Set<String>,
|
||||||
|
) {
|
||||||
|
val failed = lines.filter { it.startsWith("FAIL ") }
|
||||||
|
val passed = lines
|
||||||
|
.filter { it.startsWith("PASS ") }
|
||||||
|
.mapNotNull { Regex("""scenario=([^ ]+)""").find(it)?.groupValues?.get(1) }
|
||||||
|
.toSet()
|
||||||
|
val missing = expectedScenarios - passed
|
||||||
|
check(exitCode == 0 && failed.isEmpty() && missing.isEmpty()) {
|
||||||
|
"$label failed exitCode=$exitCode failed=$failed missing=$missing"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parserMap(): ParserMap =
|
||||||
|
mapOf(typeNameOf<TestData>() to { TestData.parseFrom(it) })
|
||||||
|
|
||||||
|
private fun goDir(): File {
|
||||||
|
var dir = File(System.getProperty("user.dir")).absoluteFile
|
||||||
|
while (dir.parentFile != null) {
|
||||||
|
val candidate = File(dir, "../go").canonicalFile
|
||||||
|
if (File(candidate, "go.mod").isFile) return candidate
|
||||||
|
val direct = File(dir, "go").canonicalFile
|
||||||
|
if (File(direct, "go.mod").isFile) return direct
|
||||||
|
dir = dir.parentFile
|
||||||
|
}
|
||||||
|
error("cannot resolve go directory")
|
||||||
|
}
|
||||||
BIN
kotlin/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
kotlin/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
kotlin/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
kotlin/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
252
kotlin/gradlew
vendored
Executable file
252
kotlin/gradlew
vendored
Executable file
|
|
@ -0,0 +1,252 @@
|
||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright © 2015-2021 the original authors.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
#
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
#
|
||||||
|
# Gradle start up script for POSIX generated by Gradle.
|
||||||
|
#
|
||||||
|
# Important for running:
|
||||||
|
#
|
||||||
|
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||||
|
# noncompliant, but you have some other compliant shell such as ksh or
|
||||||
|
# bash, then to run this script, type that shell name before the whole
|
||||||
|
# command line, like:
|
||||||
|
#
|
||||||
|
# ksh Gradle
|
||||||
|
#
|
||||||
|
# Busybox and similar reduced shells will NOT work, because this script
|
||||||
|
# requires all of these POSIX shell features:
|
||||||
|
# * functions;
|
||||||
|
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||||
|
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||||
|
# * compound commands having a testable exit status, especially «case»;
|
||||||
|
# * various built-in commands including «command», «set», and «ulimit».
|
||||||
|
#
|
||||||
|
# Important for patching:
|
||||||
|
#
|
||||||
|
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||||
|
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||||
|
#
|
||||||
|
# The "traditional" practice of packing multiple parameters into a
|
||||||
|
# space-separated string is a well documented source of bugs and security
|
||||||
|
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||||
|
# options in "$@", and eventually passing that to Java.
|
||||||
|
#
|
||||||
|
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||||
|
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||||
|
# see the in-line comments for details.
|
||||||
|
#
|
||||||
|
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||||
|
# Darwin, MinGW, and NonStop.
|
||||||
|
#
|
||||||
|
# (3) This script is generated from the Groovy template
|
||||||
|
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||||
|
# within the Gradle project.
|
||||||
|
#
|
||||||
|
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||||
|
#
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
app_path=$0
|
||||||
|
|
||||||
|
# Need this for daisy-chained symlinks.
|
||||||
|
while
|
||||||
|
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||||
|
[ -h "$app_path" ]
|
||||||
|
do
|
||||||
|
ls=$( ls -ld "$app_path" )
|
||||||
|
link=${ls#*' -> '}
|
||||||
|
case $link in #(
|
||||||
|
/*) app_path=$link ;; #(
|
||||||
|
*) app_path=$APP_HOME$link ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# This is normally unused
|
||||||
|
# shellcheck disable=SC2034
|
||||||
|
APP_BASE_NAME=${0##*/}
|
||||||
|
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||||
|
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
|
||||||
|
' "$PWD" ) || exit
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD=maximum
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "$( uname )" in #(
|
||||||
|
CYGWIN* ) cygwin=true ;; #(
|
||||||
|
Darwin* ) darwin=true ;; #(
|
||||||
|
MSYS* | MINGW* ) msys=true ;; #(
|
||||||
|
NONSTOP* ) nonstop=true ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||||
|
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||||
|
else
|
||||||
|
JAVACMD=$JAVA_HOME/bin/java
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD=java
|
||||||
|
if ! command -v java >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||||
|
case $MAX_FD in #(
|
||||||
|
max*)
|
||||||
|
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
MAX_FD=$( ulimit -H -n ) ||
|
||||||
|
warn "Could not query maximum file descriptor limit"
|
||||||
|
esac
|
||||||
|
case $MAX_FD in #(
|
||||||
|
'' | soft) :;; #(
|
||||||
|
*)
|
||||||
|
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
ulimit -n "$MAX_FD" ||
|
||||||
|
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, stacking in reverse order:
|
||||||
|
# * args from the command line
|
||||||
|
# * the main class name
|
||||||
|
# * -classpath
|
||||||
|
# * -D...appname settings
|
||||||
|
# * --module-path (only if needed)
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||||
|
|
||||||
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
|
if "$cygwin" || "$msys" ; then
|
||||||
|
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||||
|
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||||
|
|
||||||
|
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||||
|
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
for arg do
|
||||||
|
if
|
||||||
|
case $arg in #(
|
||||||
|
-*) false ;; # don't mess with options #(
|
||||||
|
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||||
|
[ -e "$t" ] ;; #(
|
||||||
|
*) false ;;
|
||||||
|
esac
|
||||||
|
then
|
||||||
|
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||||
|
fi
|
||||||
|
# Roll the args list around exactly as many times as the number of
|
||||||
|
# args, so each arg winds up back in the position where it started, but
|
||||||
|
# possibly modified.
|
||||||
|
#
|
||||||
|
# NB: a `for` loop captures its iteration list before it begins, so
|
||||||
|
# changing the positional parameters here affects neither the number of
|
||||||
|
# iterations, nor the values presented in `arg`.
|
||||||
|
shift # remove old arg
|
||||||
|
set -- "$@" "$arg" # push replacement arg
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
|
# Collect all arguments for the java command:
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||||
|
# and any embedded shellness will be escaped.
|
||||||
|
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||||
|
# treated as '${Hostname}' itself on the command line.
|
||||||
|
|
||||||
|
set -- \
|
||||||
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
|
-classpath "$CLASSPATH" \
|
||||||
|
org.gradle.wrapper.GradleWrapperMain \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
# Stop when "xargs" is not available.
|
||||||
|
if ! command -v xargs >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "xargs is not available"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use "xargs" to parse quoted args.
|
||||||
|
#
|
||||||
|
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||||
|
#
|
||||||
|
# In Bash we could simply go:
|
||||||
|
#
|
||||||
|
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||||
|
# set -- "${ARGS[@]}" "$@"
|
||||||
|
#
|
||||||
|
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||||
|
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||||
|
# character that might be a shell metacharacter, then use eval to reverse
|
||||||
|
# that process (while maintaining the separation between arguments), and wrap
|
||||||
|
# the whole thing up as a single "set" statement.
|
||||||
|
#
|
||||||
|
# This will of course break if any of these variables contains a newline or
|
||||||
|
# an unmatched quote.
|
||||||
|
#
|
||||||
|
|
||||||
|
eval "set -- $(
|
||||||
|
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||||
|
xargs -n1 |
|
||||||
|
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||||
|
tr '\n' ' '
|
||||||
|
)" '"$@"'
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
94
kotlin/gradlew.bat
vendored
Normal file
94
kotlin/gradlew.bat
vendored
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
@rem
|
||||||
|
@rem Copyright 2015 the original author or authors.
|
||||||
|
@rem
|
||||||
|
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
@rem you may not use this file except in compliance with the License.
|
||||||
|
@rem You may obtain a copy of the License at
|
||||||
|
@rem
|
||||||
|
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@rem
|
||||||
|
@rem Unless required by applicable law or agreed to in writing, software
|
||||||
|
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
@rem See the License for the specific language governing permissions and
|
||||||
|
@rem limitations under the License.
|
||||||
|
@rem
|
||||||
|
@rem SPDX-License-Identifier: Apache-2.0
|
||||||
|
@rem
|
||||||
|
|
||||||
|
@if "%DEBUG%"=="" @echo off
|
||||||
|
@rem ##########################################################################
|
||||||
|
@rem
|
||||||
|
@rem Gradle startup script for Windows
|
||||||
|
@rem
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables with windows NT shell
|
||||||
|
if "%OS%"=="Windows_NT" setlocal
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
|
@rem This is normally unused
|
||||||
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
|
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||||
|
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||||
|
|
||||||
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||||
|
|
||||||
|
@rem Find java.exe
|
||||||
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||||
|
|
||||||
|
|
||||||
|
@rem Execute Gradle
|
||||||
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||||
|
|
||||||
|
:end
|
||||||
|
@rem End local scope for the variables with windows NT shell
|
||||||
|
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||||
|
|
||||||
|
:fail
|
||||||
|
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||||
|
rem the _cmd.exe /c_ return code!
|
||||||
|
set EXIT_CODE=%ERRORLEVEL%
|
||||||
|
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||||
|
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||||
|
exit /b %EXIT_CODE%
|
||||||
|
|
||||||
|
:mainEnd
|
||||||
|
if "%OS%"=="Windows_NT" endlocal
|
||||||
|
|
||||||
|
:omega
|
||||||
17
kotlin/settings.gradle.kts
Normal file
17
kotlin/settings.gradle.kts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
gradlePluginPortal()
|
||||||
|
mavenCentral()
|
||||||
|
google()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
|
repositories {
|
||||||
|
mavenCentral()
|
||||||
|
google()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.name = "toki-socket-kotlin"
|
||||||
104
kotlin/src/main/kotlin/com/tokilabs/toki_socket/BaseClient.kt
Normal file
104
kotlin/src/main/kotlin/com/tokilabs/toki_socket/BaseClient.kt
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
package com.tokilabs.toki_socket
|
||||||
|
|
||||||
|
import com.tokilabs.toki_socket.packets.HeartBeat
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import java.util.concurrent.CopyOnWriteArrayList
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
|
||||||
|
abstract class BaseClient<Self : BaseClient<Self>>(
|
||||||
|
intervalSec: Int,
|
||||||
|
waitSec: Int,
|
||||||
|
private val doClose: () -> Unit,
|
||||||
|
internal val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
|
||||||
|
) {
|
||||||
|
abstract val self: Self
|
||||||
|
abstract val communicator: Communicator
|
||||||
|
|
||||||
|
private val heartbeatIntervalMs = intervalSec * 1000L
|
||||||
|
private val heartbeatWaitMs = waitSec * 1000L
|
||||||
|
private val closedOnce = AtomicBoolean(false)
|
||||||
|
private val hbMutex = Mutex()
|
||||||
|
private var hbTimer: HeartbeatTimer? = null
|
||||||
|
private var waitingHBResponse = false
|
||||||
|
private val disconnectListeners = CopyOnWriteArrayList<(Self) -> Unit>()
|
||||||
|
|
||||||
|
fun addDisconnectListener(handler: (Self) -> Unit) {
|
||||||
|
disconnectListeners.add(handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeDisconnectListeners() {
|
||||||
|
disconnectListeners.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
open fun close() {
|
||||||
|
if (!closedOnce.compareAndSet(false, true)) return
|
||||||
|
communicator.shutdown()
|
||||||
|
stopHeartbeat()
|
||||||
|
runCatching { doClose() }
|
||||||
|
notifyDisconnected()
|
||||||
|
scope.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendHeartBeat() {
|
||||||
|
if (!communicator.isAlive() || heartbeatIntervalMs <= 0) return
|
||||||
|
scope.launch {
|
||||||
|
hbMutex.withLock {
|
||||||
|
hbTimer?.stop()
|
||||||
|
hbTimer = HeartbeatTimer(scope, heartbeatIntervalMs) {
|
||||||
|
if (!communicator.isAlive()) return@HeartbeatTimer
|
||||||
|
hbMutex.withLock {
|
||||||
|
waitingHBResponse = true
|
||||||
|
}
|
||||||
|
runCatching { communicator.send(HeartBeat.getDefaultInstance()) }
|
||||||
|
hbMutex.withLock {
|
||||||
|
hbTimer?.stop()
|
||||||
|
hbTimer = HeartbeatTimer(scope, heartbeatWaitMs) {
|
||||||
|
if (communicator.isAlive()) onDisconnected()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onHeartBeat() {
|
||||||
|
scope.launch {
|
||||||
|
val wasWaiting = hbMutex.withLock {
|
||||||
|
if (waitingHBResponse) {
|
||||||
|
waitingHBResponse = false
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!wasWaiting) {
|
||||||
|
runCatching { communicator.send(HeartBeat.getDefaultInstance()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stopHeartbeat() {
|
||||||
|
scope.launch {
|
||||||
|
hbMutex.withLock {
|
||||||
|
hbTimer?.stop()
|
||||||
|
hbTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onDisconnected() {
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun notifyDisconnected() {
|
||||||
|
val listeners = disconnectListeners.toList()
|
||||||
|
disconnectListeners.clear()
|
||||||
|
listeners.forEach { it(self) }
|
||||||
|
}
|
||||||
|
}
|
||||||
319
kotlin/src/main/kotlin/com/tokilabs/toki_socket/Communicator.kt
Normal file
319
kotlin/src/main/kotlin/com/tokilabs/toki_socket/Communicator.kt
Normal file
|
|
@ -0,0 +1,319 @@
|
||||||
|
package com.tokilabs.toki_socket
|
||||||
|
|
||||||
|
import com.google.protobuf.Message
|
||||||
|
import com.google.protobuf.MessageLite
|
||||||
|
import com.tokilabs.toki_socket.packets.HeartBeat
|
||||||
|
import com.tokilabs.toki_socket.packets.PacketBase
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.channels.ClosedSendChannelException
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.selects.select
|
||||||
|
import kotlinx.coroutines.withTimeout
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
import java.util.concurrent.locks.ReentrantReadWriteLock
|
||||||
|
import kotlin.concurrent.read
|
||||||
|
import kotlin.concurrent.write
|
||||||
|
import kotlin.reflect.KClass
|
||||||
|
|
||||||
|
class NotConnectedException : IllegalStateException("not connected")
|
||||||
|
|
||||||
|
interface Transport {
|
||||||
|
suspend fun writePacket(base: PacketBase)
|
||||||
|
|
||||||
|
fun close()
|
||||||
|
}
|
||||||
|
|
||||||
|
typealias ParserMap = Map<String, (ByteArray) -> MessageLite>
|
||||||
|
|
||||||
|
private typealias RequestHandler = suspend (MessageLite, Int) -> Unit
|
||||||
|
|
||||||
|
private data class PendingRequest(
|
||||||
|
val expectedTypeName: String,
|
||||||
|
val ch: Channel<MessageLite> = Channel(capacity = 1),
|
||||||
|
val errCh: Channel<Throwable> = Channel(capacity = 1),
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class QueuedPacket(
|
||||||
|
val base: PacketBase,
|
||||||
|
val done: CompletableDeferred<Throwable?> = CompletableDeferred(),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun typeNameOf(m: MessageLite): String =
|
||||||
|
(m as Message).descriptorForType.fullName
|
||||||
|
|
||||||
|
@PublishedApi
|
||||||
|
internal fun <T : Message> defaultMessage(kClass: KClass<T>): T {
|
||||||
|
val method = kClass.java.getDeclaredMethod("getDefaultInstance")
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
return method.invoke(null) as T
|
||||||
|
}
|
||||||
|
|
||||||
|
inline fun <reified T : Message> typeNameOf(): String =
|
||||||
|
typeNameOf(defaultMessage(T::class))
|
||||||
|
|
||||||
|
class Communicator(
|
||||||
|
private val transport: Transport,
|
||||||
|
parserMap: ParserMap,
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
) {
|
||||||
|
private val rwLock = ReentrantReadWriteLock()
|
||||||
|
private val nonce = AtomicInteger(0)
|
||||||
|
private val isAlive = AtomicBoolean(false)
|
||||||
|
private val parserMap: MutableMap<String, (ByteArray) -> MessageLite> = ConcurrentHashMap()
|
||||||
|
private val handlers = mutableMapOf<String, MutableList<(MessageLite) -> Unit>>()
|
||||||
|
private val reqHandlers = mutableMapOf<String, RequestHandler>()
|
||||||
|
private val pendingRequests = mutableMapOf<Int, PendingRequest>()
|
||||||
|
private val writeQueue = Channel<QueuedPacket>(capacity = 64)
|
||||||
|
private val closed = CompletableDeferred<Unit>()
|
||||||
|
private val shutdownStarted = AtomicBoolean(false)
|
||||||
|
private var writeErrorHandler: ((Throwable) -> Unit)? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
initialize(parserMap)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun initialize(parserMap: ParserMap) {
|
||||||
|
this.parserMap.putAll(parserMap)
|
||||||
|
this.parserMap[typeNameOf<HeartBeat>()] = { HeartBeat.parseFrom(it) }
|
||||||
|
isAlive.set(true)
|
||||||
|
scope.launch { writeLoop() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isAlive(): Boolean = isAlive.get()
|
||||||
|
|
||||||
|
fun setWriteErrorHandler(fn: (Throwable) -> Unit) {
|
||||||
|
rwLock.write { writeErrorHandler = fn }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun nextNonce(): Int = nonce.incrementAndGet()
|
||||||
|
|
||||||
|
fun shutdown() {
|
||||||
|
if (!shutdownStarted.compareAndSet(false, true)) return
|
||||||
|
isAlive.set(false)
|
||||||
|
writeQueue.close()
|
||||||
|
closed.complete(Unit)
|
||||||
|
|
||||||
|
val pending = rwLock.write {
|
||||||
|
val snapshot = pendingRequests.values.toList()
|
||||||
|
pendingRequests.clear()
|
||||||
|
snapshot
|
||||||
|
}
|
||||||
|
pending.forEach { it.errCh.trySend(NotConnectedException()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun close() {
|
||||||
|
shutdown()
|
||||||
|
transport.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun queuePacket(base: PacketBase) {
|
||||||
|
if (!isAlive()) throw NotConnectedException()
|
||||||
|
val item = QueuedPacket(base)
|
||||||
|
try {
|
||||||
|
select<Unit> {
|
||||||
|
writeQueue.onSend(item) {}
|
||||||
|
closed.onAwait { throw NotConnectedException() }
|
||||||
|
}
|
||||||
|
} catch (e: ClosedSendChannelException) {
|
||||||
|
throw NotConnectedException()
|
||||||
|
}
|
||||||
|
|
||||||
|
val error = select {
|
||||||
|
item.done.onAwait { it }
|
||||||
|
closed.onAwait { NotConnectedException() }
|
||||||
|
}
|
||||||
|
if (error != null) throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun send(m: MessageLite) {
|
||||||
|
if (!isAlive()) throw NotConnectedException()
|
||||||
|
queuePacket(
|
||||||
|
PacketBase.newBuilder()
|
||||||
|
.setTypeName(typeNameOf(m))
|
||||||
|
.setNonce(nextNonce())
|
||||||
|
.setData(m.toByteString())
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun sendRequest(
|
||||||
|
req: MessageLite,
|
||||||
|
resTypeName: String,
|
||||||
|
timeoutMs: Long = 30_000L,
|
||||||
|
): MessageLite {
|
||||||
|
if (!isAlive()) throw NotConnectedException()
|
||||||
|
val requestNonce = nextNonce()
|
||||||
|
val pending = PendingRequest(expectedTypeName = resTypeName)
|
||||||
|
|
||||||
|
rwLock.write { pendingRequests[requestNonce] = pending }
|
||||||
|
|
||||||
|
try {
|
||||||
|
queuePacket(
|
||||||
|
PacketBase.newBuilder()
|
||||||
|
.setTypeName(typeNameOf(req))
|
||||||
|
.setNonce(requestNonce)
|
||||||
|
.setData(req.toByteString())
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
removePending(requestNonce)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
|
||||||
|
return try {
|
||||||
|
withTimeout(if (timeoutMs > 0) timeoutMs else 30_000L) {
|
||||||
|
select {
|
||||||
|
pending.ch.onReceive { it }
|
||||||
|
pending.errCh.onReceive { throw it }
|
||||||
|
closed.onAwait { throw NotConnectedException() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: kotlinx.coroutines.TimeoutCancellationException) {
|
||||||
|
removePending(requestNonce)
|
||||||
|
throw IllegalStateException("request timeout for nonce $requestNonce", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addListener(typeName: String, fn: (MessageLite) -> Unit) {
|
||||||
|
rwLock.write {
|
||||||
|
check(!reqHandlers.containsKey(typeName)) {
|
||||||
|
"type $typeName is already registered with addRequestListener"
|
||||||
|
}
|
||||||
|
handlers.getOrPut(typeName) { mutableListOf() }.add(fn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeListeners(typeName: String) {
|
||||||
|
rwLock.write { handlers.remove(typeName) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addRequestListener(typeName: String, fn: RequestHandler) {
|
||||||
|
rwLock.write {
|
||||||
|
check(handlers[typeName].isNullOrEmpty()) {
|
||||||
|
"type $typeName is already registered with addListener"
|
||||||
|
}
|
||||||
|
check(!reqHandlers.containsKey(typeName)) {
|
||||||
|
"type $typeName is already registered with addRequestListener"
|
||||||
|
}
|
||||||
|
reqHandlers[typeName] = fn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onReceivedData(
|
||||||
|
typeName: String,
|
||||||
|
data: ByteArray,
|
||||||
|
incomingNonce: Int = 0,
|
||||||
|
responseNonce: Int = 0,
|
||||||
|
) {
|
||||||
|
if (responseNonce > 0) {
|
||||||
|
handleResponse(typeName, data, responseNonce)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val reqHandler: RequestHandler?
|
||||||
|
val listeners: List<(MessageLite) -> Unit>
|
||||||
|
rwLock.read {
|
||||||
|
reqHandler = reqHandlers[typeName]
|
||||||
|
listeners = handlers[typeName]?.toList().orEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reqHandler != null) {
|
||||||
|
val msg = runCatching { parse(typeName, data) }.getOrNull() ?: return
|
||||||
|
scope.launch { reqHandler(msg, incomingNonce) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (listeners.isEmpty()) return
|
||||||
|
val msg = runCatching { parse(typeName, data) }.getOrNull() ?: return
|
||||||
|
listeners.forEach { it(msg) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun writeLoop() {
|
||||||
|
for (item in writeQueue) {
|
||||||
|
val error = runCatching { transport.writePacket(item.base) }.exceptionOrNull()
|
||||||
|
item.done.complete(error)
|
||||||
|
if (error != null) {
|
||||||
|
val handler = rwLock.read { writeErrorHandler }
|
||||||
|
handler?.invoke(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleResponse(
|
||||||
|
typeName: String,
|
||||||
|
data: ByteArray,
|
||||||
|
responseNonce: Int,
|
||||||
|
) {
|
||||||
|
val pending = removePending(responseNonce) ?: return
|
||||||
|
if (typeName != pending.expectedTypeName) {
|
||||||
|
pending.errCh.trySend(
|
||||||
|
IllegalStateException(
|
||||||
|
"response type mismatch for nonce $responseNonce: " +
|
||||||
|
"expected ${pending.expectedTypeName}, got $typeName",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val msg = runCatching { parse(typeName, data) }
|
||||||
|
msg.onSuccess { pending.ch.trySend(it) }
|
||||||
|
.onFailure { pending.errCh.trySend(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun parse(typeName: String, data: ByteArray): MessageLite {
|
||||||
|
val parser = parserMap[typeName]
|
||||||
|
?: throw IllegalStateException("protobuf parser is not registered for type $typeName")
|
||||||
|
return parser(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun removePending(nonce: Int): PendingRequest? =
|
||||||
|
rwLock.write { pendingRequests.remove(nonce) }
|
||||||
|
}
|
||||||
|
|
||||||
|
inline fun <reified T : Message> addListenerTyped(
|
||||||
|
communicator: Communicator,
|
||||||
|
noinline fn: (T) -> Unit,
|
||||||
|
) {
|
||||||
|
val typeName = typeNameOf<T>()
|
||||||
|
communicator.addListener(typeName) { message ->
|
||||||
|
val typed = message as? T
|
||||||
|
?: throw IllegalStateException("received ${message::class} for listener $typeName")
|
||||||
|
fn(typed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inline fun <reified Req : Message, reified Res : Message> addRequestListenerTyped(
|
||||||
|
communicator: Communicator,
|
||||||
|
noinline fn: suspend (Req) -> Res,
|
||||||
|
) {
|
||||||
|
val reqTypeName = typeNameOf<Req>()
|
||||||
|
communicator.addRequestListener(reqTypeName) { message, requestNonce ->
|
||||||
|
val req = message as? Req
|
||||||
|
if (req != null) {
|
||||||
|
val res = fn(req)
|
||||||
|
if (communicator.isAlive()) {
|
||||||
|
communicator.queuePacket(
|
||||||
|
PacketBase.newBuilder()
|
||||||
|
.setTypeName(typeNameOf(res))
|
||||||
|
.setNonce(communicator.nextNonce())
|
||||||
|
.setResponseNonce(requestNonce)
|
||||||
|
.setData(res.toByteString())
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend inline fun <reified Req : Message, reified Res : Message> sendRequestTyped(
|
||||||
|
communicator: Communicator,
|
||||||
|
req: Req,
|
||||||
|
timeoutMs: Long = 30_000L,
|
||||||
|
): Res {
|
||||||
|
val msg = communicator.sendRequest(req, typeNameOf<Res>(), timeoutMs)
|
||||||
|
return msg as? Res
|
||||||
|
?: throw IllegalStateException("received ${msg::class}, expected ${Res::class}")
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
package com.tokilabs.toki_socket
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class HeartbeatTimer(
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
private val delayMs: Long,
|
||||||
|
private val callback: suspend () -> Unit,
|
||||||
|
) {
|
||||||
|
private var job: Job? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reset(delayMs: Long = this.delayMs) {
|
||||||
|
job?.cancel()
|
||||||
|
job = scope.launch {
|
||||||
|
delay(delayMs)
|
||||||
|
callback()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
job?.cancel()
|
||||||
|
job = null
|
||||||
|
}
|
||||||
|
}
|
||||||
129
kotlin/src/main/kotlin/com/tokilabs/toki_socket/TcpClient.kt
Normal file
129
kotlin/src/main/kotlin/com/tokilabs/toki_socket/TcpClient.kt
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
package com.tokilabs.toki_socket
|
||||||
|
|
||||||
|
import com.tokilabs.toki_socket.packets.HeartBeat
|
||||||
|
import com.tokilabs.toki_socket.packets.PacketBase
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.DataInputStream
|
||||||
|
import java.io.IOException
|
||||||
|
import java.net.InetSocketAddress
|
||||||
|
import java.net.Socket
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
|
||||||
|
class TcpClient private constructor(
|
||||||
|
private val socket: Socket,
|
||||||
|
intervalSec: Int,
|
||||||
|
waitSec: Int,
|
||||||
|
parserMap: ParserMap,
|
||||||
|
) : BaseClient<TcpClient>(
|
||||||
|
intervalSec = intervalSec,
|
||||||
|
waitSec = waitSec,
|
||||||
|
doClose = { socket.close() },
|
||||||
|
),
|
||||||
|
Transport {
|
||||||
|
override val self: TcpClient
|
||||||
|
get() = this
|
||||||
|
|
||||||
|
override val communicator: Communicator = Communicator(this, parserMap, scope)
|
||||||
|
private val writeMutex = Mutex()
|
||||||
|
|
||||||
|
init {
|
||||||
|
communicator.setWriteErrorHandler { onDisconnected() }
|
||||||
|
communicator.addListener(typeNameOf<HeartBeat>()) { onHeartBeat() }
|
||||||
|
readLoop()
|
||||||
|
sendHeartBeat()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun writePacket(base: PacketBase) {
|
||||||
|
val bytes = base.toByteArray()
|
||||||
|
if (bytes.size > MAX_PACKET_SIZE) {
|
||||||
|
throw IOException("packet size ${bytes.size} exceeds max $MAX_PACKET_SIZE")
|
||||||
|
}
|
||||||
|
val header = ByteBuffer.allocate(4).putInt(bytes.size).array()
|
||||||
|
writeMutex.withLock {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val output = socket.getOutputStream()
|
||||||
|
output.write(header)
|
||||||
|
output.write(bytes)
|
||||||
|
output.flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readLoop() {
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
val input = DataInputStream(socket.getInputStream())
|
||||||
|
while (communicator.isAlive()) {
|
||||||
|
try {
|
||||||
|
val length = input.readInt()
|
||||||
|
if (length == 0) continue
|
||||||
|
if (length < 0 || length > MAX_PACKET_SIZE) {
|
||||||
|
onDisconnected()
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
val bytes = ByteArray(length)
|
||||||
|
input.readFully(bytes)
|
||||||
|
val base = PacketBase.parseFrom(bytes)
|
||||||
|
communicator.onReceivedData(
|
||||||
|
base.typeName,
|
||||||
|
base.data.toByteArray(),
|
||||||
|
base.nonce,
|
||||||
|
base.responseNonce,
|
||||||
|
)
|
||||||
|
sendHeartBeat()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
onDisconnected()
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun send(message: com.google.protobuf.MessageLite) {
|
||||||
|
communicator.send(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val MAX_PACKET_SIZE: Int = 64 * 1024 * 1024
|
||||||
|
|
||||||
|
fun fromSocket(
|
||||||
|
socket: Socket,
|
||||||
|
intervalSec: Int = 30,
|
||||||
|
waitSec: Int = 10,
|
||||||
|
parserMap: ParserMap,
|
||||||
|
): TcpClient = TcpClient(socket, intervalSec, waitSec, parserMap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dialTcp(
|
||||||
|
host: String,
|
||||||
|
port: Int,
|
||||||
|
intervalSec: Int = 30,
|
||||||
|
waitSec: Int = 10,
|
||||||
|
parserMap: ParserMap,
|
||||||
|
): TcpClient {
|
||||||
|
val socket = Socket()
|
||||||
|
socket.connect(InetSocketAddress(host, port))
|
||||||
|
return TcpClient.fromSocket(socket, intervalSec, waitSec, parserMap)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("FunctionName")
|
||||||
|
fun DialTcp(
|
||||||
|
host: String,
|
||||||
|
port: Int,
|
||||||
|
intervalSec: Int = 30,
|
||||||
|
waitSec: Int = 10,
|
||||||
|
parserMap: ParserMap,
|
||||||
|
): TcpClient = dialTcp(host, port, intervalSec, waitSec, parserMap)
|
||||||
|
|
||||||
|
fun TcpClient.closeBlocking() {
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun TcpClient.sendBlocking(message: com.google.protobuf.MessageLite) {
|
||||||
|
runBlocking { send(message) }
|
||||||
|
}
|
||||||
77
kotlin/src/main/kotlin/com/tokilabs/toki_socket/TcpServer.kt
Normal file
77
kotlin/src/main/kotlin/com/tokilabs/toki_socket/TcpServer.kt
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
package com.tokilabs.toki_socket
|
||||||
|
|
||||||
|
import com.google.protobuf.MessageLite
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import java.net.InetSocketAddress
|
||||||
|
import java.net.ServerSocket
|
||||||
|
import java.net.SocketException
|
||||||
|
import java.util.concurrent.CopyOnWriteArrayList
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
|
||||||
|
class TcpServer(
|
||||||
|
private val host: String,
|
||||||
|
private val port: Int,
|
||||||
|
private val newClient: (java.net.Socket) -> TcpClient,
|
||||||
|
) {
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
private val clients = CopyOnWriteArrayList<TcpClient>()
|
||||||
|
private val started = AtomicBoolean(false)
|
||||||
|
private var serverSocket: ServerSocket? = null
|
||||||
|
|
||||||
|
var onClientConnected: (TcpClient) -> Unit = {}
|
||||||
|
|
||||||
|
fun started(): Boolean = started.get()
|
||||||
|
|
||||||
|
fun clients(): List<TcpClient> = clients.toList()
|
||||||
|
|
||||||
|
fun start() {
|
||||||
|
if (!started.compareAndSet(false, true)) return
|
||||||
|
val server = ServerSocket()
|
||||||
|
server.reuseAddress = true
|
||||||
|
server.bind(InetSocketAddress(host, port))
|
||||||
|
serverSocket = server
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
acceptLoop(server)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
if (!started.compareAndSet(true, false)) return
|
||||||
|
runCatching { serverSocket?.close() }
|
||||||
|
val snapshot = clients.toList()
|
||||||
|
clients.clear()
|
||||||
|
snapshot.forEach { it.close() }
|
||||||
|
scope.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun broadcast(message: MessageLite) {
|
||||||
|
clients().forEach { client ->
|
||||||
|
client.communicator.send(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun acceptLoop(server: ServerSocket) {
|
||||||
|
while (started.get()) {
|
||||||
|
try {
|
||||||
|
val socket = server.accept()
|
||||||
|
val client = newClient(socket)
|
||||||
|
client.addDisconnectListener { clients.remove(it) }
|
||||||
|
clients.add(client)
|
||||||
|
onClientConnected(client)
|
||||||
|
} catch (_: SocketException) {
|
||||||
|
return
|
||||||
|
} catch (_: Exception) {
|
||||||
|
if (!started.get()) return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun TcpServer.broadcastBlocking(message: MessageLite) {
|
||||||
|
runBlocking { broadcast(message) }
|
||||||
|
}
|
||||||
228
kotlin/src/main/kotlin/com/tokilabs/toki_socket/WsClient.kt
Normal file
228
kotlin/src/main/kotlin/com/tokilabs/toki_socket/WsClient.kt
Normal file
|
|
@ -0,0 +1,228 @@
|
||||||
|
package com.tokilabs.toki_socket
|
||||||
|
|
||||||
|
import com.tokilabs.toki_socket.packets.HeartBeat
|
||||||
|
import com.tokilabs.toki_socket.packets.PacketBase
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
|
import okhttp3.WebSocketListener
|
||||||
|
import okio.ByteString
|
||||||
|
import org.java_websocket.WebSocket
|
||||||
|
import java.io.IOException
|
||||||
|
import java.security.KeyStore
|
||||||
|
import java.util.concurrent.CountDownLatch
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import java.util.concurrent.atomic.AtomicReference
|
||||||
|
import javax.net.ssl.SSLContext
|
||||||
|
import javax.net.ssl.TrustManagerFactory
|
||||||
|
import javax.net.ssl.X509TrustManager
|
||||||
|
|
||||||
|
class WsClient internal constructor(
|
||||||
|
private val connection: WsConnection,
|
||||||
|
intervalSec: Int,
|
||||||
|
waitSec: Int,
|
||||||
|
parserMap: ParserMap,
|
||||||
|
) : BaseClient<WsClient>(
|
||||||
|
intervalSec = intervalSec,
|
||||||
|
waitSec = waitSec,
|
||||||
|
doClose = { connection.close() },
|
||||||
|
),
|
||||||
|
Transport {
|
||||||
|
override val self: WsClient
|
||||||
|
get() = this
|
||||||
|
|
||||||
|
override val communicator: Communicator = Communicator(this, parserMap, scope)
|
||||||
|
|
||||||
|
init {
|
||||||
|
communicator.setWriteErrorHandler { onDisconnected() }
|
||||||
|
communicator.addListener(typeNameOf<HeartBeat>()) { onHeartBeat() }
|
||||||
|
sendHeartBeat()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun writePacket(base: PacketBase) {
|
||||||
|
val bytes = base.toByteArray()
|
||||||
|
if (!connection.send(bytes)) {
|
||||||
|
throw IOException("websocket send failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun receiveBytes(bytes: ByteArray) {
|
||||||
|
try {
|
||||||
|
val base = PacketBase.parseFrom(bytes)
|
||||||
|
communicator.onReceivedData(
|
||||||
|
base.typeName,
|
||||||
|
base.data.toByteArray(),
|
||||||
|
base.nonce,
|
||||||
|
base.responseNonce,
|
||||||
|
)
|
||||||
|
sendHeartBeat()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
onDisconnected()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun handleFailure() {
|
||||||
|
onDisconnected()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun send(message: com.google.protobuf.MessageLite) {
|
||||||
|
communicator.send(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun forServer(
|
||||||
|
conn: WebSocket,
|
||||||
|
intervalSec: Int,
|
||||||
|
waitSec: Int,
|
||||||
|
parserMap: ParserMap,
|
||||||
|
): WsClient = WsClient(JavaWebSocketConnection(conn), intervalSec, waitSec, parserMap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal interface WsConnection {
|
||||||
|
fun send(bytes: ByteArray): Boolean
|
||||||
|
|
||||||
|
fun close()
|
||||||
|
}
|
||||||
|
|
||||||
|
private class JavaWebSocketConnection(
|
||||||
|
private val conn: WebSocket,
|
||||||
|
) : WsConnection {
|
||||||
|
override fun send(bytes: ByteArray): Boolean =
|
||||||
|
runCatching {
|
||||||
|
conn.send(bytes)
|
||||||
|
true
|
||||||
|
}.getOrElse { false }
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
runCatching { conn.close(1000) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class OkHttpWsConnection(
|
||||||
|
private val client: OkHttpClient,
|
||||||
|
) : WsConnection {
|
||||||
|
private val socketRef = AtomicReference<okhttp3.WebSocket?>()
|
||||||
|
|
||||||
|
fun setSocket(ws: okhttp3.WebSocket) {
|
||||||
|
socketRef.set(ws)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun send(bytes: ByteArray): Boolean =
|
||||||
|
socketRef.get()?.send(ByteString.of(*bytes)) ?: false
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
runCatching { socketRef.get()?.close(1000, null) }
|
||||||
|
client.dispatcher.executorService.shutdown()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dialWs(
|
||||||
|
host: String,
|
||||||
|
port: Int,
|
||||||
|
path: String,
|
||||||
|
intervalSec: Int = 30,
|
||||||
|
waitSec: Int = 10,
|
||||||
|
parserMap: ParserMap,
|
||||||
|
): WsClient = dialWsUrl("ws://$host:$port$path", null, intervalSec, waitSec, parserMap)
|
||||||
|
|
||||||
|
fun dialWss(
|
||||||
|
host: String,
|
||||||
|
port: Int,
|
||||||
|
path: String,
|
||||||
|
sslContext: SSLContext?,
|
||||||
|
intervalSec: Int = 30,
|
||||||
|
waitSec: Int = 10,
|
||||||
|
parserMap: ParserMap,
|
||||||
|
): WsClient = dialWsUrl("wss://$host:$port$path", sslContext, intervalSec, waitSec, parserMap)
|
||||||
|
|
||||||
|
private fun dialWsUrl(
|
||||||
|
url: String,
|
||||||
|
sslContext: SSLContext?,
|
||||||
|
intervalSec: Int,
|
||||||
|
waitSec: Int,
|
||||||
|
parserMap: ParserMap,
|
||||||
|
): WsClient {
|
||||||
|
val builder = OkHttpClient.Builder()
|
||||||
|
if (sslContext != null) {
|
||||||
|
builder.sslSocketFactory(sslContext.socketFactory, defaultTrustManager())
|
||||||
|
}
|
||||||
|
val okHttpClient = builder.build()
|
||||||
|
val connection = OkHttpWsConnection(okHttpClient)
|
||||||
|
val opened = CountDownLatch(1)
|
||||||
|
val failed = AtomicReference<Throwable?>()
|
||||||
|
var client: WsClient? = null
|
||||||
|
val listener = object : WebSocketListener() {
|
||||||
|
override fun onOpen(webSocket: okhttp3.WebSocket, response: Response) {
|
||||||
|
connection.setSocket(webSocket)
|
||||||
|
opened.countDown()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMessage(webSocket: okhttp3.WebSocket, bytes: ByteString) {
|
||||||
|
client?.receiveBytes(bytes.toByteArray())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onClosing(webSocket: okhttp3.WebSocket, code: Int, reason: String) {
|
||||||
|
webSocket.close(code, reason)
|
||||||
|
client?.handleFailure()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onClosed(webSocket: okhttp3.WebSocket, code: Int, reason: String) {
|
||||||
|
client?.handleFailure()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onFailure(webSocket: okhttp3.WebSocket, t: Throwable, response: Response?) {
|
||||||
|
failed.set(t)
|
||||||
|
opened.countDown()
|
||||||
|
client?.handleFailure()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
client = WsClient(connection, intervalSec, waitSec, parserMap)
|
||||||
|
val ws = okHttpClient.newWebSocket(Request.Builder().url(url).build(), listener)
|
||||||
|
connection.setSocket(ws)
|
||||||
|
if (!opened.await(5, TimeUnit.SECONDS)) {
|
||||||
|
client.close()
|
||||||
|
throw IOException("websocket open timed out")
|
||||||
|
}
|
||||||
|
failed.get()?.let {
|
||||||
|
client.close()
|
||||||
|
throw IOException("websocket open failed", it)
|
||||||
|
}
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun defaultTrustManager(): X509TrustManager {
|
||||||
|
val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
|
||||||
|
factory.init(null as KeyStore?)
|
||||||
|
return factory.trustManagers
|
||||||
|
.filterIsInstance<X509TrustManager>()
|
||||||
|
.singleOrNull()
|
||||||
|
?: throw IllegalStateException("expected exactly one default X509TrustManager")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("FunctionName")
|
||||||
|
fun DialWs(
|
||||||
|
host: String,
|
||||||
|
port: Int,
|
||||||
|
path: String,
|
||||||
|
intervalSec: Int = 30,
|
||||||
|
waitSec: Int = 10,
|
||||||
|
parserMap: ParserMap,
|
||||||
|
): WsClient = dialWs(host, port, path, intervalSec, waitSec, parserMap)
|
||||||
|
|
||||||
|
@Suppress("FunctionName")
|
||||||
|
fun DialWss(
|
||||||
|
host: String,
|
||||||
|
port: Int,
|
||||||
|
path: String,
|
||||||
|
sslContext: SSLContext?,
|
||||||
|
intervalSec: Int = 30,
|
||||||
|
waitSec: Int = 10,
|
||||||
|
parserMap: ParserMap,
|
||||||
|
): WsClient = dialWss(host, port, path, sslContext, intervalSec, waitSec, parserMap)
|
||||||
|
|
||||||
|
fun WsClient.sendBlocking(message: com.google.protobuf.MessageLite) {
|
||||||
|
runBlocking { send(message) }
|
||||||
|
}
|
||||||
93
kotlin/src/main/kotlin/com/tokilabs/toki_socket/WsServer.kt
Normal file
93
kotlin/src/main/kotlin/com/tokilabs/toki_socket/WsServer.kt
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
package com.tokilabs.toki_socket
|
||||||
|
|
||||||
|
import com.google.protobuf.MessageLite
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.java_websocket.WebSocket
|
||||||
|
import org.java_websocket.handshake.ClientHandshake
|
||||||
|
import org.java_websocket.server.WebSocketServer
|
||||||
|
import java.net.InetSocketAddress
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import java.util.concurrent.CopyOnWriteArrayList
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
|
||||||
|
class WsServer(
|
||||||
|
host: String,
|
||||||
|
port: Int,
|
||||||
|
private val path: String = "/",
|
||||||
|
private val newClient: (WebSocket) -> WsClient,
|
||||||
|
) : WebSocketServer(InetSocketAddress(host, port)) {
|
||||||
|
private val clientByConn = ConcurrentHashMap<WebSocket, WsClient>()
|
||||||
|
private val clients = CopyOnWriteArrayList<WsClient>()
|
||||||
|
private val startedFlag = AtomicBoolean(false)
|
||||||
|
|
||||||
|
var onClientConnected: (WsClient) -> Unit = {}
|
||||||
|
|
||||||
|
fun started(): Boolean = startedFlag.get()
|
||||||
|
|
||||||
|
fun clients(): List<WsClient> = clients.toList()
|
||||||
|
|
||||||
|
override fun start() {
|
||||||
|
startedFlag.set(true)
|
||||||
|
super.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun stop() {
|
||||||
|
if (!startedFlag.compareAndSet(true, false)) return
|
||||||
|
val snapshot = clients.toList()
|
||||||
|
clients.clear()
|
||||||
|
snapshot.forEach { it.close() }
|
||||||
|
runCatching { super.stop(1000) }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun broadcast(message: MessageLite) {
|
||||||
|
clients().forEach { client ->
|
||||||
|
client.communicator.send(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onOpen(conn: WebSocket, handshake: ClientHandshake) {
|
||||||
|
if (handshake.resourceDescriptor != path) {
|
||||||
|
conn.close(1008, "unexpected path")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val client = newClient(conn)
|
||||||
|
client.addDisconnectListener {
|
||||||
|
clients.remove(it)
|
||||||
|
clientByConn.remove(conn)
|
||||||
|
}
|
||||||
|
clientByConn[conn] = client
|
||||||
|
clients.add(client)
|
||||||
|
onClientConnected(client)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMessage(conn: WebSocket, message: String) {
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMessage(conn: WebSocket, message: ByteBuffer) {
|
||||||
|
val bytes = ByteArray(message.remaining())
|
||||||
|
message.get(bytes)
|
||||||
|
clientByConn[conn]?.receiveBytes(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onClose(
|
||||||
|
conn: WebSocket,
|
||||||
|
code: Int,
|
||||||
|
reason: String,
|
||||||
|
remote: Boolean,
|
||||||
|
) {
|
||||||
|
clientByConn.remove(conn)?.handleFailure()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onError(conn: WebSocket?, ex: Exception) {
|
||||||
|
if (conn == null) return
|
||||||
|
clientByConn[conn]?.handleFailure()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStart() {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun WsServer.broadcastBlocking(message: MessageLite) {
|
||||||
|
runBlocking { broadcast(message) }
|
||||||
|
}
|
||||||
19
kotlin/src/main/proto/message_common.proto
Normal file
19
kotlin/src/main/proto/message_common.proto
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
option java_package = "com.tokilabs.toki_socket.packets";
|
||||||
|
option java_outer_classname = "MessageCommonProto";
|
||||||
|
option java_multiple_files = true;
|
||||||
|
|
||||||
|
message PacketBase {
|
||||||
|
string typeName = 1;
|
||||||
|
int32 nonce = 2;
|
||||||
|
bytes data = 3;
|
||||||
|
int32 responseNonce = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message HeartBeat {}
|
||||||
|
|
||||||
|
message TestData {
|
||||||
|
int32 index = 1;
|
||||||
|
string message = 2;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,95 @@
|
||||||
|
package com.tokilabs.toki_socket
|
||||||
|
|
||||||
|
import com.tokilabs.toki_socket.packets.HeartBeat
|
||||||
|
import com.tokilabs.toki_socket.packets.PacketBase
|
||||||
|
import com.tokilabs.toki_socket.packets.TestData
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import java.util.Collections
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFailsWith
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
private class FakeTransport : Transport {
|
||||||
|
val packets: MutableList<PacketBase> = Collections.synchronizedList(mutableListOf())
|
||||||
|
|
||||||
|
override suspend fun writePacket(base: PacketBase) {
|
||||||
|
packets.add(base)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class CommunicatorTest {
|
||||||
|
@Test
|
||||||
|
fun testSendRequestTimeout() = runTest {
|
||||||
|
val transport = FakeTransport()
|
||||||
|
val communicator = Communicator(transport, testParserMap(), CoroutineScope(SupervisorJob() + Dispatchers.Default))
|
||||||
|
|
||||||
|
val err = assertFailsWith<IllegalStateException> {
|
||||||
|
sendRequestTyped<TestData, TestData>(
|
||||||
|
communicator,
|
||||||
|
TestData.newBuilder().setIndex(1).setMessage("no response").build(),
|
||||||
|
timeoutMs = 25,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
assertTrue(err.message!!.contains("timeout"))
|
||||||
|
assertEquals(1, transport.packets.size)
|
||||||
|
communicator.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testSendRequestTypeMismatch() = runTest {
|
||||||
|
val transport = FakeTransport()
|
||||||
|
val communicator = Communicator(transport, testParserMap(), CoroutineScope(SupervisorJob() + Dispatchers.Default))
|
||||||
|
|
||||||
|
val result = async {
|
||||||
|
assertFailsWith<IllegalStateException> {
|
||||||
|
sendRequestTyped<TestData, TestData>(
|
||||||
|
communicator,
|
||||||
|
TestData.newBuilder().setIndex(1).setMessage("hello").build(),
|
||||||
|
timeoutMs = 1_000,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while (transport.packets.isEmpty()) delay(1)
|
||||||
|
val requestNonce = transport.packets.single().nonce
|
||||||
|
communicator.onReceivedData(
|
||||||
|
typeNameOf<HeartBeat>(),
|
||||||
|
HeartBeat.getDefaultInstance().toByteArray(),
|
||||||
|
responseNonce = requestNonce,
|
||||||
|
)
|
||||||
|
assertTrue(result.await().message!!.contains("response type mismatch"))
|
||||||
|
communicator.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testListenerAndRequestListenerConflict() = runTest {
|
||||||
|
val transport = FakeTransport()
|
||||||
|
val communicator = Communicator(transport, testParserMap(), CoroutineScope(SupervisorJob() + Dispatchers.Default))
|
||||||
|
|
||||||
|
addListenerTyped<TestData>(communicator) {}
|
||||||
|
assertFailsWith<IllegalStateException> {
|
||||||
|
addRequestListenerTyped<TestData, TestData>(communicator) { it }
|
||||||
|
}
|
||||||
|
communicator.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testSendFireAndForget() = runTest {
|
||||||
|
val transport = FakeTransport()
|
||||||
|
val communicator = Communicator(transport, testParserMap(), CoroutineScope(SupervisorJob() + Dispatchers.Default))
|
||||||
|
|
||||||
|
communicator.send(TestData.newBuilder().setIndex(7).setMessage("fire").build())
|
||||||
|
|
||||||
|
assertEquals(1, transport.packets.size)
|
||||||
|
assertEquals(typeNameOf<TestData>(), transport.packets.single().typeName)
|
||||||
|
communicator.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
107
kotlin/src/test/kotlin/com/tokilabs/toki_socket/HeartbeatTest.kt
Normal file
107
kotlin/src/test/kotlin/com/tokilabs/toki_socket/HeartbeatTest.kt
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
package com.tokilabs.toki_socket
|
||||||
|
|
||||||
|
import com.tokilabs.toki_socket.packets.HeartBeat
|
||||||
|
import com.tokilabs.toki_socket.packets.PacketBase
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import java.util.Collections
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
private class HeartbeatTransport : Transport {
|
||||||
|
val packets: MutableList<PacketBase> = Collections.synchronizedList(mutableListOf())
|
||||||
|
|
||||||
|
override suspend fun writePacket(base: PacketBase) {
|
||||||
|
packets.add(base)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class HeartbeatClient(
|
||||||
|
private val transport: HeartbeatTransport,
|
||||||
|
intervalSec: Int,
|
||||||
|
waitSec: Int,
|
||||||
|
) : BaseClient<HeartbeatClient>(
|
||||||
|
intervalSec = intervalSec,
|
||||||
|
waitSec = waitSec,
|
||||||
|
doClose = { transport.close() },
|
||||||
|
) {
|
||||||
|
override val self: HeartbeatClient
|
||||||
|
get() = this
|
||||||
|
|
||||||
|
override val communicator = Communicator(transport, testParserMap(), scope)
|
||||||
|
|
||||||
|
init {
|
||||||
|
communicator.addListener(typeNameOf<HeartBeat>()) { onHeartBeat() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class HeartbeatTest {
|
||||||
|
@Test
|
||||||
|
fun testHeartbeatSentAfterInactivity() = runBlocking {
|
||||||
|
val transport = HeartbeatTransport()
|
||||||
|
val client = HeartbeatClient(transport, 1, 1)
|
||||||
|
|
||||||
|
client.sendHeartBeat()
|
||||||
|
delay(1_100)
|
||||||
|
|
||||||
|
assertTrue(transport.packets.any { it.typeName == typeNameOf<HeartBeat>() })
|
||||||
|
client.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testHeartbeatDisconnectOnNoResponse() = runBlocking {
|
||||||
|
val transport = HeartbeatTransport()
|
||||||
|
val client = HeartbeatClient(transport, 1, 1)
|
||||||
|
var disconnected = false
|
||||||
|
client.addDisconnectListener { disconnected = true }
|
||||||
|
|
||||||
|
client.sendHeartBeat()
|
||||||
|
delay(2_300)
|
||||||
|
|
||||||
|
assertTrue(disconnected)
|
||||||
|
assertTrue(!client.communicator.isAlive())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testHeartbeatTimerResetBySendHeartBeat() = runBlocking {
|
||||||
|
val transport = HeartbeatTransport()
|
||||||
|
val client = HeartbeatClient(transport, 1, 1)
|
||||||
|
|
||||||
|
client.sendHeartBeat()
|
||||||
|
delay(700)
|
||||||
|
client.sendHeartBeat()
|
||||||
|
delay(500)
|
||||||
|
assertTrue(transport.packets.none { it.typeName == typeNameOf<HeartBeat>() })
|
||||||
|
delay(700)
|
||||||
|
assertTrue(transport.packets.any { it.typeName == typeNameOf<HeartBeat>() })
|
||||||
|
client.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testHeartbeatTimerResetOnReceivedData() = runBlocking {
|
||||||
|
val transport = HeartbeatTransport()
|
||||||
|
val client = HeartbeatClient(transport, 1, 1)
|
||||||
|
var received = false
|
||||||
|
client.communicator.addListener(typeNameOf(testData())) {
|
||||||
|
received = true
|
||||||
|
}
|
||||||
|
|
||||||
|
client.sendHeartBeat()
|
||||||
|
delay(700)
|
||||||
|
client.communicator.onReceivedData(
|
||||||
|
typeNameOf(testData()),
|
||||||
|
testData().toByteArray(),
|
||||||
|
incomingNonce = 1,
|
||||||
|
)
|
||||||
|
assertTrue(received)
|
||||||
|
client.sendHeartBeat()
|
||||||
|
delay(500)
|
||||||
|
assertTrue(transport.packets.none { it.typeName == typeNameOf<HeartBeat>() })
|
||||||
|
delay(700)
|
||||||
|
assertTrue(transport.packets.any { it.typeName == typeNameOf<HeartBeat>() })
|
||||||
|
client.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
package com.tokilabs.toki_socket
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class HeartbeatTimerTest {
|
||||||
|
@Test
|
||||||
|
fun testCallbackFires() = runTest {
|
||||||
|
var fired = false
|
||||||
|
HeartbeatTimer(this, 100) {
|
||||||
|
fired = true
|
||||||
|
}
|
||||||
|
|
||||||
|
testScheduler.advanceTimeBy(101)
|
||||||
|
|
||||||
|
assertTrue(fired)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testStopPreventsCallback() = runTest {
|
||||||
|
var fired = false
|
||||||
|
val timer = HeartbeatTimer(this, 100) {
|
||||||
|
fired = true
|
||||||
|
}
|
||||||
|
timer.stop()
|
||||||
|
|
||||||
|
testScheduler.advanceTimeBy(150)
|
||||||
|
|
||||||
|
assertFalse(fired)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testResetRestartsTimer() = runTest {
|
||||||
|
val fired = CompletableDeferred<Unit>()
|
||||||
|
val timer = HeartbeatTimer(this, 100) {
|
||||||
|
fired.complete(Unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
testScheduler.advanceTimeBy(80)
|
||||||
|
timer.reset()
|
||||||
|
testScheduler.advanceTimeBy(80)
|
||||||
|
assertFalse(fired.isCompleted)
|
||||||
|
testScheduler.advanceTimeBy(21)
|
||||||
|
assertTrue(fired.isCompleted)
|
||||||
|
}
|
||||||
|
}
|
||||||
114
kotlin/src/test/kotlin/com/tokilabs/toki_socket/TcpTest.kt
Normal file
114
kotlin/src/test/kotlin/com/tokilabs/toki_socket/TcpTest.kt
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
package com.tokilabs.toki_socket
|
||||||
|
|
||||||
|
import com.tokilabs.toki_socket.packets.TestData
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import java.net.Socket
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
|
||||||
|
class TcpTest {
|
||||||
|
@Test
|
||||||
|
fun testTcpSendReceive() = runBlocking {
|
||||||
|
val port = freePort()
|
||||||
|
val received = CompletableDeferred<TestData>()
|
||||||
|
val server = TcpServer("127.0.0.1", port) { socket ->
|
||||||
|
TcpClient.fromSocket(socket, 0, 0, testParserMap())
|
||||||
|
}
|
||||||
|
server.onClientConnected = { client ->
|
||||||
|
addListenerTyped<TestData>(client.communicator) { received.complete(it) }
|
||||||
|
}
|
||||||
|
server.start()
|
||||||
|
val client = DialTcp("127.0.0.1", port, 0, 0, testParserMap())
|
||||||
|
|
||||||
|
client.send(TestData.newBuilder().setIndex(42).setMessage("hello toki-socket").build())
|
||||||
|
|
||||||
|
assertEquals(42, received.await().index)
|
||||||
|
client.close()
|
||||||
|
server.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testTcpRequestResponse() = runBlocking {
|
||||||
|
val port = freePort()
|
||||||
|
val server = TcpServer("127.0.0.1", port) { socket ->
|
||||||
|
TcpClient.fromSocket(socket, 0, 0, testParserMap())
|
||||||
|
}
|
||||||
|
server.onClientConnected = { client ->
|
||||||
|
addRequestListenerTyped<TestData, TestData>(client.communicator) { req ->
|
||||||
|
TestData.newBuilder()
|
||||||
|
.setIndex(req.index * 2)
|
||||||
|
.setMessage("echo: ${req.message}")
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
server.start()
|
||||||
|
val client = DialTcp("127.0.0.1", port, 0, 0, testParserMap())
|
||||||
|
|
||||||
|
val res = sendRequestTyped<TestData, TestData>(
|
||||||
|
client.communicator,
|
||||||
|
TestData.newBuilder().setIndex(21).setMessage("hello").build(),
|
||||||
|
timeoutMs = 2_000,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(42, res.index)
|
||||||
|
assertEquals("echo: hello", res.message)
|
||||||
|
client.close()
|
||||||
|
server.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testTcpBroadcast() = runBlocking {
|
||||||
|
val port = freePort()
|
||||||
|
val server = TcpServer("127.0.0.1", port) { socket ->
|
||||||
|
TcpClient.fromSocket(socket, 0, 0, testParserMap())
|
||||||
|
}
|
||||||
|
server.start()
|
||||||
|
val client = DialTcp("127.0.0.1", port, 0, 0, testParserMap())
|
||||||
|
val received = CompletableDeferred<TestData>()
|
||||||
|
addListenerTyped<TestData>(client.communicator) { received.complete(it) }
|
||||||
|
|
||||||
|
waitForCondition(message = "client did not connect") { server.clients().size == 1 }
|
||||||
|
server.broadcast(TestData.newBuilder().setIndex(9).setMessage("broadcast").build())
|
||||||
|
|
||||||
|
assertEquals("broadcast", received.await().message)
|
||||||
|
client.close()
|
||||||
|
server.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testTcpServerStopDisconnectsClients() = runBlocking {
|
||||||
|
val port = freePort()
|
||||||
|
val server = TcpServer("127.0.0.1", port) { socket ->
|
||||||
|
TcpClient.fromSocket(socket, 0, 0, testParserMap())
|
||||||
|
}
|
||||||
|
server.start()
|
||||||
|
val client = DialTcp("127.0.0.1", port, 0, 0, testParserMap())
|
||||||
|
val disconnected = CompletableDeferred<Unit>()
|
||||||
|
client.addDisconnectListener { disconnected.complete(Unit) }
|
||||||
|
|
||||||
|
waitForCondition(message = "client did not connect") { server.clients().size == 1 }
|
||||||
|
server.stop()
|
||||||
|
|
||||||
|
disconnected.await()
|
||||||
|
assertFalse(client.communicator.isAlive())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testTcpClientCloseIdempotent() = runBlocking {
|
||||||
|
val serverSocket = java.net.ServerSocket(0)
|
||||||
|
val accept = async(Dispatchers.IO) { serverSocket.accept() }
|
||||||
|
val socket = Socket("127.0.0.1", serverSocket.localPort)
|
||||||
|
val peer = accept.await()
|
||||||
|
val client = TcpClient.fromSocket(socket, 0, 0, testParserMap())
|
||||||
|
|
||||||
|
repeat(3) { client.close() }
|
||||||
|
|
||||||
|
assertFalse(client.communicator.isAlive())
|
||||||
|
peer.close()
|
||||||
|
serverSocket.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
package com.tokilabs.toki_socket
|
||||||
|
|
||||||
|
import com.tokilabs.toki_socket.packets.TestData
|
||||||
|
import java.net.ServerSocket
|
||||||
|
import kotlin.test.fail
|
||||||
|
|
||||||
|
fun testParserMap(): ParserMap =
|
||||||
|
mapOf(
|
||||||
|
typeNameOf<TestData>() to { TestData.parseFrom(it) },
|
||||||
|
)
|
||||||
|
|
||||||
|
fun testData(): TestData =
|
||||||
|
TestData.newBuilder()
|
||||||
|
.setIndex(1)
|
||||||
|
.setMessage("ping")
|
||||||
|
.build()
|
||||||
|
|
||||||
|
fun freePort(): Int =
|
||||||
|
ServerSocket(0).use { it.localPort }
|
||||||
|
|
||||||
|
suspend fun waitForCondition(
|
||||||
|
timeoutMs: Long = 2_000L,
|
||||||
|
intervalMs: Long = 10L,
|
||||||
|
message: String,
|
||||||
|
predicate: () -> Boolean,
|
||||||
|
) {
|
||||||
|
val deadline = System.nanoTime() + timeoutMs * 1_000_000L
|
||||||
|
while (System.nanoTime() < deadline) {
|
||||||
|
if (predicate()) return
|
||||||
|
kotlinx.coroutines.delay(intervalMs)
|
||||||
|
}
|
||||||
|
fail(message)
|
||||||
|
}
|
||||||
101
kotlin/src/test/kotlin/com/tokilabs/toki_socket/WsTest.kt
Normal file
101
kotlin/src/test/kotlin/com/tokilabs/toki_socket/WsTest.kt
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
package com.tokilabs.toki_socket
|
||||||
|
|
||||||
|
import com.tokilabs.toki_socket.packets.TestData
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
|
||||||
|
class WsTest {
|
||||||
|
@Test
|
||||||
|
fun testWsSendReceive() = runBlocking {
|
||||||
|
val port = freePort()
|
||||||
|
val received = CompletableDeferred<TestData>()
|
||||||
|
val server = WsServer("127.0.0.1", port, "/") { conn ->
|
||||||
|
WsClient.forServer(conn, 0, 0, testParserMap())
|
||||||
|
}
|
||||||
|
server.onClientConnected = { client ->
|
||||||
|
addListenerTyped<TestData>(client.communicator) { received.complete(it) }
|
||||||
|
}
|
||||||
|
server.start()
|
||||||
|
val client = DialWs("127.0.0.1", port, "/", 0, 0, testParserMap())
|
||||||
|
|
||||||
|
client.send(TestData.newBuilder().setIndex(42).setMessage("hello toki-socket ws").build())
|
||||||
|
|
||||||
|
assertEquals(42, received.await().index)
|
||||||
|
client.close()
|
||||||
|
server.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testWsRequestResponse() = runBlocking {
|
||||||
|
val port = freePort()
|
||||||
|
val server = WsServer("127.0.0.1", port, "/") { conn ->
|
||||||
|
WsClient.forServer(conn, 0, 0, testParserMap())
|
||||||
|
}
|
||||||
|
server.onClientConnected = { client ->
|
||||||
|
addRequestListenerTyped<TestData, TestData>(client.communicator) { req ->
|
||||||
|
TestData.newBuilder()
|
||||||
|
.setIndex(req.index * 2)
|
||||||
|
.setMessage("echo: ${req.message}")
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
server.start()
|
||||||
|
val client = DialWs("127.0.0.1", port, "/", 0, 0, testParserMap())
|
||||||
|
|
||||||
|
val res = sendRequestTyped<TestData, TestData>(
|
||||||
|
client.communicator,
|
||||||
|
TestData.newBuilder().setIndex(21).setMessage("hello ws").build(),
|
||||||
|
timeoutMs = 2_000,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(42, res.index)
|
||||||
|
assertEquals("echo: hello ws", res.message)
|
||||||
|
client.close()
|
||||||
|
server.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testWsBroadcast() = runBlocking {
|
||||||
|
val port = freePort()
|
||||||
|
val server = WsServer("127.0.0.1", port, "/") { conn ->
|
||||||
|
WsClient.forServer(conn, 0, 0, testParserMap())
|
||||||
|
}
|
||||||
|
server.start()
|
||||||
|
val client1 = DialWs("127.0.0.1", port, "/", 0, 0, testParserMap())
|
||||||
|
val client2 = DialWs("127.0.0.1", port, "/", 0, 0, testParserMap())
|
||||||
|
val received1 = CompletableDeferred<TestData>()
|
||||||
|
val received2 = CompletableDeferred<TestData>()
|
||||||
|
addListenerTyped<TestData>(client1.communicator) { received1.complete(it) }
|
||||||
|
addListenerTyped<TestData>(client2.communicator) { received2.complete(it) }
|
||||||
|
|
||||||
|
waitForCondition(message = "clients did not connect") { server.clients().size == 2 }
|
||||||
|
server.broadcast(TestData.newBuilder().setIndex(9).setMessage("ws broadcast").build())
|
||||||
|
|
||||||
|
assertEquals("ws broadcast", received1.await().message)
|
||||||
|
assertEquals("ws broadcast", received2.await().message)
|
||||||
|
client1.close()
|
||||||
|
client2.close()
|
||||||
|
server.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testWsServerStopDisconnectsClients() = runBlocking {
|
||||||
|
val port = freePort()
|
||||||
|
val server = WsServer("127.0.0.1", port, "/") { conn ->
|
||||||
|
WsClient.forServer(conn, 0, 0, testParserMap())
|
||||||
|
}
|
||||||
|
server.start()
|
||||||
|
val client = DialWs("127.0.0.1", port, "/", 0, 0, testParserMap())
|
||||||
|
val disconnected = CompletableDeferred<Unit>()
|
||||||
|
client.addDisconnectListener { disconnected.complete(Unit) }
|
||||||
|
|
||||||
|
waitForCondition(message = "client did not connect") { server.clients().size == 1 }
|
||||||
|
server.stop()
|
||||||
|
|
||||||
|
disconnected.await()
|
||||||
|
assertFalse(client.communicator.isAlive())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
---
|
---
|
||||||
name: code-review
|
name: code-review
|
||||||
description: Review completed implementation work in the current repository. Reads PLAN.md and CODE_REVIEW.md from the active task, reviews the actual changed source files, appends a verdict to CODE_REVIEW.md, then archives both files as .log. If Required or Suggested issues are found (FAIL or WARN), writes a new PLAN.md and CODE_REVIEW.md stub so the loop continues immediately. Nit-only findings may still PASS.
|
description: Review completed implementation work in the current repository. Reads PLAN.md and CODE_REVIEW.md from the active task, reviews the actual changed source files, appends a verdict to CODE_REVIEW.md, then archives both files as .log. On PASS, writes complete.log summarising the full loop history. If Required or Suggested issues are found (FAIL or WARN), writes a new PLAN.md and CODE_REVIEW.md stub so the loop continues immediately. Nit-only findings may still PASS.
|
||||||
---
|
---
|
||||||
|
|
||||||
# Code Review
|
# Code Review
|
||||||
|
|
@ -24,7 +24,8 @@ Directory states:
|
||||||
| State | Meaning |
|
| State | Meaning |
|
||||||
|-------|---------|
|
|-------|---------|
|
||||||
| `PLAN.md` + `CODE_REVIEW.md` | Ready for review |
|
| `PLAN.md` + `CODE_REVIEW.md` | Ready for review |
|
||||||
| Only `*.log` files | Task complete |
|
| `complete.log` + `*.log` files | Task complete (PASS) |
|
||||||
|
| Only `*.log` files (no `complete.log`) | Task terminated mid-loop or abandoned |
|
||||||
|
|
||||||
The implementing agent never archives or deletes active files; archiving is this skill's responsibility.
|
The implementing agent never archives or deletes active files; archiving is this skill's responsibility.
|
||||||
|
|
||||||
|
|
@ -93,11 +94,20 @@ After archiving, neither active `.md` file remains unless Step 6 writes a follow
|
||||||
|
|
||||||
## Step 6 - Post-Review Actions
|
## Step 6 - Post-Review Actions
|
||||||
|
|
||||||
For `PASS`, report:
|
For `PASS`, write `tasks/{task_name}/complete.log` before reporting:
|
||||||
|
|
||||||
|
Required fields in `complete.log`:
|
||||||
|
- `완료 일시`: date completed.
|
||||||
|
- `요약`: one-line task description and loop count.
|
||||||
|
- `루프 이력`: table of plan/code_review log pairs with their verdict.
|
||||||
|
- `최종 리뷰 요약`: bullet list of what was implemented.
|
||||||
|
- `잔여 Nit`: any Nit-only findings recorded but not acted on (omit section if none).
|
||||||
|
|
||||||
|
Then report:
|
||||||
|
|
||||||
- Verdict.
|
- Verdict.
|
||||||
- Archive filenames.
|
- Archive filenames.
|
||||||
- Task complete; only `.log` files remain.
|
- `complete.log` written; task complete.
|
||||||
|
|
||||||
For `WARN` or `FAIL`, write a new `PLAN.md` and `CODE_REVIEW.md` stub using the plan skill format:
|
For `WARN` or `FAIL`, write a new `PLAN.md` and `CODE_REVIEW.md` stub using the plan skill format:
|
||||||
|
|
||||||
|
|
@ -106,7 +116,75 @@ For `WARN` or `FAIL`, write a new `PLAN.md` and `CODE_REVIEW.md` stub using the
|
||||||
- `FAIL`: one plan item per Required issue.
|
- `FAIL`: one plan item per Required issue.
|
||||||
- `WARN`: one grouped plan item for Suggested issues, plus related Nit issues if useful.
|
- `WARN`: one grouped plan item for Suggested issues, plus related Nit issues if useful.
|
||||||
- Each plan item needs problem, solution with before/after when non-trivial, checklist, test decision, intermediate verification.
|
- Each plan item needs problem, solution with before/after when non-trivial, checklist, test decision, intermediate verification.
|
||||||
- New `CODE_REVIEW.md` stub must use the same header and list every new plan item unchecked.
|
|
||||||
|
`CODE_REVIEW.md` stub template (fill `{…}` placeholders; everything else is fixed and must not be changed by the implementing agent):
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
<!-- task={task_name} plan={N} tag={TAG} -->
|
||||||
|
|
||||||
|
# Code Review Reference - {TAG}
|
||||||
|
|
||||||
|
## 개요
|
||||||
|
|
||||||
|
task={task_name}, plan={N}, tag={TAG}
|
||||||
|
|
||||||
|
## 이 파일을 읽는 리뷰 에이전트에게
|
||||||
|
|
||||||
|
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
|
||||||
|
리뷰 완료 후 반드시 아래 순서로 아카이브하세요.
|
||||||
|
|
||||||
|
1. `CODE_REVIEW.md` → `code_review_N.log` (N = 기존 code_review_*.log 수)
|
||||||
|
2. `PLAN.md` → `plan_M.log` (M = 기존 plan_*.log 수)
|
||||||
|
3. PASS인 경우 `complete.log` 작성 후 종료. WARN/FAIL인 경우 새 `PLAN.md` + `CODE_REVIEW.md` 스텁 작성.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 구현 항목별 완료 여부
|
||||||
|
|
||||||
|
| 항목 | 완료 여부 |
|
||||||
|
|------|---------|
|
||||||
|
| [{TAG}-1] {item description} | [ ] |
|
||||||
|
| [{TAG}-2] {item description} | [ ] |
|
||||||
|
|
||||||
|
## 계획 대비 변경 사항
|
||||||
|
|
||||||
|
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
|
||||||
|
|
||||||
|
## 주요 설계 결정
|
||||||
|
|
||||||
|
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
|
||||||
|
|
||||||
|
## 리뷰어를 위한 체크포인트
|
||||||
|
|
||||||
|
{pre-filled from plan — one bullet per review focus area}
|
||||||
|
|
||||||
|
## 검증 결과
|
||||||
|
|
||||||
|
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
|
||||||
|
|
||||||
|
### {TAG}-1 중간 검증
|
||||||
|
```
|
||||||
|
$ {verification command from plan}
|
||||||
|
(output)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 최종 검증
|
||||||
|
```
|
||||||
|
$ {final verification command from plan}
|
||||||
|
(output)
|
||||||
|
```
|
||||||
|
```
|
||||||
|
|
||||||
|
Sections and their ownership:
|
||||||
|
|
||||||
|
| 섹션 | 소유자 | 설명 |
|
||||||
|
|------|--------|------|
|
||||||
|
| 헤더 주석, 개요, 리뷰 에이전트 지시 | 스텁 생성 시 고정 | 구현 에이전트가 수정하지 않음 |
|
||||||
|
| 구현 항목별 완료 여부 (항목명) | 스텁 생성 시 고정 | `[ ]` → `[x]` 체크만 구현 에이전트가 수행 |
|
||||||
|
| 계획 대비 변경 사항, 주요 설계 결정 | 구현 에이전트가 채움 | placeholder 텍스트를 실제 내용으로 교체 |
|
||||||
|
| 리뷰어를 위한 체크포인트 | 스텁 생성 시 고정 | 계획에서 추출한 리뷰 포인트 |
|
||||||
|
| 검증 결과 (섹션 제목 + 명령) | 스텁 생성 시 고정 | 실행 출력만 구현 에이전트가 채움 |
|
||||||
|
| 코드리뷰 결과 | 리뷰 에이전트가 append | 스텁에 포함하지 않음 |
|
||||||
|
|
||||||
Report Required/Suggested counts, archive names, and the new plan path.
|
Report Required/Suggested counts, archive names, and the new plan path.
|
||||||
|
|
||||||
|
|
@ -135,4 +213,5 @@ Report Required/Suggested counts, archive names, and the new plan path.
|
||||||
- `code_review_N.log` exists with verdict appended.
|
- `code_review_N.log` exists with verdict appended.
|
||||||
- `plan_M.log` exists.
|
- `plan_M.log` exists.
|
||||||
- No active `.md` files remain after PASS.
|
- No active `.md` files remain after PASS.
|
||||||
- WARN/FAIL created new active `PLAN.md` and `CODE_REVIEW.md` with matching headers.
|
- PASS: `complete.log` written with loop history, implementation summary, and residual Nits.
|
||||||
|
- WARN/FAIL: new active `PLAN.md` and `CODE_REVIEW.md` created with matching headers; no `complete.log`.
|
||||||
|
|
|
||||||
233
tasks/kotlin_impl/code_review_0.log
Normal file
233
tasks/kotlin_impl/code_review_0.log
Normal file
|
|
@ -0,0 +1,233 @@
|
||||||
|
<!-- task=kotlin_impl plan=0 tag=API -->
|
||||||
|
|
||||||
|
# Code Review Reference - API
|
||||||
|
|
||||||
|
## 개요
|
||||||
|
|
||||||
|
task=kotlin_impl, plan=0, tag=API
|
||||||
|
|
||||||
|
## 구현 항목별 완료 여부
|
||||||
|
|
||||||
|
| 항목 | 완료 여부 |
|
||||||
|
|------|---------|
|
||||||
|
| [API-1] Kotlin 프로젝트 구조 및 proto 바인딩 설정 | [x] 구현 완료 / JVM 부재로 Gradle 검증 차단 |
|
||||||
|
| [API-2] Communicator 구현 | [x] 구현 완료 / JVM 부재로 Gradle 검증 차단 |
|
||||||
|
| [API-3] HeartbeatTimer 구현 | [x] 구현 완료 / JVM 부재로 Gradle 검증 차단 |
|
||||||
|
| [API-4] BaseClient 구현 | [x] 구현 완료 / JVM 부재로 Gradle 검증 차단 |
|
||||||
|
| [API-5] TcpClient / TcpServer 구현 | [x] 구현 완료 / JVM 부재로 Gradle 검증 차단 |
|
||||||
|
| [API-6] WsClient / WsServer 구현 | [x] 구현 완료 / JVM 부재로 Gradle 검증 차단 |
|
||||||
|
| [API-7] Heartbeat 통합 테스트 | [x] 작성 완료 / JVM 부재로 Gradle 검증 차단 |
|
||||||
|
| [API-8] Go ↔ Kotlin 크로스테스트 | [x] 작성 완료 / JVM 부재로 Kotlin 실행 차단 |
|
||||||
|
|
||||||
|
## 계획 대비 변경 사항
|
||||||
|
|
||||||
|
- `go/crosstest/go_dart.go`와 `go/crosstest/go_kotlin.go`에 `//go:build ignore`를 추가했다. 두 파일 모두 단독 `go run ./crosstest/<file>.go` 오케스트레이터라서 같은 `package main` 안에 둘 경우 `go test ./...`에서 `main` 및 helper symbol이 충돌하기 때문이다. 명시 파일 `go run`은 유지된다.
|
||||||
|
- `tools/check_proto_sync.sh`가 Kotlin proto copy도 검사하도록 확장했다. Java/Kotlin 생성용 `option java_*`만 canonical diff에서 제외한다.
|
||||||
|
- `README.md`와 `PROTOCOL.md`의 Kotlin 상태는 `Available`이 아니라 `In progress`로 기록했다. 현재 컨테이너에 Java runtime이 없어 Kotlin 테스트와 크로스테스트를 실제 통과시키지 못했기 때문이다.
|
||||||
|
- Heartbeat 통합 테스트는 `runTest` 가상 시간이 아니라 `runBlocking` 실제 시간 기반으로 작성했다. `BaseClient`가 `Dispatchers.IO` scope를 소유하는 현재 구조에서는 가상 스케줄러만으로 transport 통합 흐름이 진행되지 않는다.
|
||||||
|
- `DialWss`는 API 표면을 추가했지만, 주어진 `SSLContext`에서 `X509TrustManager`를 안전하게 복원하는 경로는 아직 보수적으로 제한되어 있다. WSS 실검증은 후속 JVM 환경에서 보강 대상이다.
|
||||||
|
|
||||||
|
## 주요 설계 결정
|
||||||
|
|
||||||
|
- Kotlin proto는 schema package를 추가하지 않고 Java/Kotlin generation option만 둔다. `typeNameOf`는 `descriptorForType.fullName`을 사용하므로 현재 wire key는 Go/Dart와 같은 `TestData`, `HeartBeat`이다.
|
||||||
|
- `Communicator`는 `Channel<QueuedPacket>(64)` 기반 단일 write loop로 stream write interleaving을 방지한다. pending request는 `responseNonce`로 제거하며, type mismatch는 waiting caller에 오류로 전달한다.
|
||||||
|
- `addListener`와 `addRequestListener`는 `ReentrantReadWriteLock` write lock 안에서 상호 배타 조건을 검사한다.
|
||||||
|
- `BaseClient.close()`는 `AtomicBoolean.compareAndSet(false, true)`로 멱등성을 보장하고, communicator shutdown, heartbeat stop, transport close, disconnect listener notify 순서로 처리한다.
|
||||||
|
- TCP는 4-byte big-endian length prefix와 64 MiB max packet guard를 적용한다. WebSocket은 binary frame 하나에 `PacketBase` protobuf bytes를 싣는다.
|
||||||
|
- Go crosstest runner는 Go 서버/Kotlin 클라이언트, Kotlin runner는 Kotlin 서버/Go 클라이언트 방향을 각각 담당한다.
|
||||||
|
|
||||||
|
## 리뷰어를 위한 체크포인트
|
||||||
|
|
||||||
|
- Communicator의 `addListener` / `addRequestListener` 상호 배타 로직이 race-free한가?
|
||||||
|
- `close()` 멱등성: `AtomicBoolean.compareAndSet` 패턴이 모든 코드 경로에서 일관되게 적용됐는가?
|
||||||
|
- writeLoop가 `writeQueue.close()` 후 정상 종료되는가? (pending done 채널에 오류 전달 여부 확인)
|
||||||
|
- TCP readLoop에서 `length > MAX_PACKET_SIZE` 조건이 실제로 disconnect를 트리거하는가?
|
||||||
|
- WsClient OkHttp `WebSocketListener.onFailure` → `onDisconnected` 경로가 누락되지 않았는가?
|
||||||
|
- HeartbeatTimer가 `BaseClient.close()` 이후 callback을 발사하지 않는가? (scope 취소 순서 확인)
|
||||||
|
- 크로스테스트 포트(29290, 29292, 29390, 29392)가 기존 테스트 포트와 충돌하지 않는가?
|
||||||
|
- typeName이 `TestData` (단순 이름, no package prefix)로 Go / Dart 측과 일치하는가?
|
||||||
|
|
||||||
|
## 검증 결과
|
||||||
|
|
||||||
|
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
|
||||||
|
|
||||||
|
### API-1 중간 검증
|
||||||
|
```
|
||||||
|
$ cd kotlin && ./gradlew compileKotlin
|
||||||
|
|
||||||
|
ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation.
|
||||||
|
```
|
||||||
|
|
||||||
|
### API-2 중간 검증
|
||||||
|
```
|
||||||
|
$ cd kotlin && ./gradlew test --tests "*.CommunicatorTest"
|
||||||
|
|
||||||
|
ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation.
|
||||||
|
```
|
||||||
|
|
||||||
|
### API-3 중간 검증
|
||||||
|
```
|
||||||
|
$ cd kotlin && ./gradlew test --tests "*.HeartbeatTimerTest"
|
||||||
|
|
||||||
|
Not rerun separately: same Gradle/JVM blocker as API-2.
|
||||||
|
```
|
||||||
|
|
||||||
|
### API-4 중간 검증
|
||||||
|
```
|
||||||
|
$ cd kotlin && ./gradlew compileKotlin
|
||||||
|
|
||||||
|
Not rerun separately: same Gradle/JVM blocker as API-1.
|
||||||
|
```
|
||||||
|
|
||||||
|
### API-5 중간 검증
|
||||||
|
```
|
||||||
|
$ cd kotlin && ./gradlew test --tests "*.TcpTest"
|
||||||
|
|
||||||
|
Not rerun separately: same Gradle/JVM blocker as API-2.
|
||||||
|
```
|
||||||
|
|
||||||
|
### API-6 중간 검증
|
||||||
|
```
|
||||||
|
$ cd kotlin && ./gradlew test --tests "*.WsTest"
|
||||||
|
|
||||||
|
Not rerun separately: same Gradle/JVM blocker as API-2.
|
||||||
|
```
|
||||||
|
|
||||||
|
### API-7 중간 검증
|
||||||
|
```
|
||||||
|
$ cd kotlin && ./gradlew test --tests "*.HeartbeatTest"
|
||||||
|
|
||||||
|
Not rerun separately: same Gradle/JVM blocker as API-2.
|
||||||
|
```
|
||||||
|
|
||||||
|
### API-8 중간 검증
|
||||||
|
```
|
||||||
|
$ cd go && PATH=/config/go-sdk/go/bin:/config/go/bin:$PATH GOCACHE=/tmp/go-build GOMODCACHE=/tmp/go-mod go run ./crosstest/go_kotlin.go
|
||||||
|
INFO typeName go=TestData
|
||||||
|
|
||||||
|
ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation.
|
||||||
|
|
||||||
|
FAIL crosstest error=kotlin-client tcp/send-push failed waitErr=exit status 1 failed=[] missing=[1 2]
|
||||||
|
exit status 1
|
||||||
|
```
|
||||||
|
|
||||||
|
### 최종 검증
|
||||||
|
```
|
||||||
|
$ cd go && PATH=/config/go-sdk/go/bin:/config/go/bin:$PATH GOCACHE=/tmp/go-build GOMODCACHE=/tmp/go-mod go test ./...
|
||||||
|
? toki-labs.com/toki_socket/go [no test files]
|
||||||
|
? toki-labs.com/toki_socket/go/crosstest/dart_go_client [no test files]
|
||||||
|
? toki-labs.com/toki_socket/go/crosstest/kotlin_go_client [no test files]
|
||||||
|
? toki-labs.com/toki_socket/go/examples/tcp_echo [no test files]
|
||||||
|
? toki-labs.com/toki_socket/go/examples/ws_echo [no test files]
|
||||||
|
? toki-labs.com/toki_socket/go/packets [no test files]
|
||||||
|
ok toki-labs.com/toki_socket/go/test (cached)
|
||||||
|
|
||||||
|
$ ./tools/check_proto_sync.sh
|
||||||
|
Proto schemas are in sync.
|
||||||
|
|
||||||
|
$ cd kotlin && ./gradlew test
|
||||||
|
|
||||||
|
ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 코드리뷰 결과
|
||||||
|
|
||||||
|
### 종합 판정
|
||||||
|
|
||||||
|
**WARN**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 차원별 평가
|
||||||
|
|
||||||
|
| 차원 | 판정 | 비고 |
|
||||||
|
|------|------|------|
|
||||||
|
| 정확성 (Correctness) | Pass | 프로토콜 wire format, nonce 단조 증가, close-once, addListener/addRequestListener 상호 배타, pending request 취소 모두 올바름 |
|
||||||
|
| 완성도 (Completeness) | Pass | 8개 항목 전부 구현됨. proto sync 검증, Go 테스트 회귀 없음 확인 |
|
||||||
|
| 테스트 커버리지 (Test coverage) | Warn | HeartbeatTest의 reset 테스트가 실제 메시지 수신 경로를 커버하지 않음 |
|
||||||
|
| API 계약 (API contract) | Warn | `crosstest/` 소스가 main sourceset에 포함되어 라이브러리 JAR에 crosstest 코드가 실려감 |
|
||||||
|
| 코드 품질 (Code quality) | Warn | `WsServer.stop()`의 `InterruptedException` 미처리, `kotlin_go.kt` 내 `runBlocking` in listener |
|
||||||
|
| 계획 대비 변경 (Plan deviation) | Pass | 모든 변경이 CODE_REVIEW.md에 기록되고 이유가 명확함 |
|
||||||
|
| 검증 신뢰도 (Verification trust) | Pass | JVM 환경 부재로 Kotlin 검증 불가, 가능한 Go 검증은 모두 실행됨. 제약이 명확히 기록됨 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 발견된 문제
|
||||||
|
|
||||||
|
**Suggested — `WsServer.stop()`: `super.stop(1000)` `InterruptedException` 미처리**
|
||||||
|
`kotlin/src/main/kotlin/com/tokilabs/toki_socket/WsServer.kt:36-41`
|
||||||
|
|
||||||
|
`WebSocketServer.stop(int)` 는 Java 메서드로 `InterruptedException` 을 선언한다. Kotlin은 checked exception을 컴파일 타임에 강제하지 않지만, `runBlocking` 컨텍스트에서 `InterruptedException` 이 던져지면 현재 coroutine이 취소 신호로 처리해 `stop()` 이후 로직이 실행되지 않는다.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// 현재
|
||||||
|
super.stop(1000)
|
||||||
|
|
||||||
|
// 수정
|
||||||
|
runCatching { super.stop(1000) }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Suggested — `crosstest/` 코드가 main sourceset에 포함됨**
|
||||||
|
`kotlin/build.gradle.kts:14`
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
named("main") {
|
||||||
|
kotlin.srcDir("crosstest")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`kotlin_go.kt` 와 `go_kotlin_client/` 의 `Main.kt` 가 라이브러리 JAR에 함께 포함된다. crosstest 코드는 별도 sourceset(`crosstestMain`) 이나 `application` 전용 소스 경로로 분리해야 한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Suggested — `kotlin_go.kt` listener callback 내 `runBlocking` 사용**
|
||||||
|
`kotlin/crosstest/kotlin_go.kt:56-63`, `104-111`
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
addListenerTyped<TestData>(client.communicator) { data ->
|
||||||
|
...
|
||||||
|
if (valid) {
|
||||||
|
runBlocking { client.send(...) } // ← Dispatchers.IO 스레드 블로킹
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`onReceivedData` 는 `TcpClient.readLoop` 의 `Dispatchers.IO` coroutine 내에서 호출된다. listener callback에서 `runBlocking` 을 호출하면 IO 스레드를 점유한다. `scope.launch { client.send(...) }` 또는 listener를 `suspend` 람다로 받는 패턴으로 교체해야 한다.
|
||||||
|
|
||||||
|
현재 `addListenerTyped` 의 시그니처가 `fn: (T) -> Unit` 이므로 즉시 수정하려면 코드 내부에서 `scope.launch { }` 로 감싸거나, crosstest 코드 한정으로 별도 scope를 사용해야 한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Suggested — `HeartbeatTest.testHeartbeatResetOnReceive()`: 수신 경로 미검증**
|
||||||
|
`kotlin/src/test/kotlin/com/tokilabs/toki_socket/HeartbeatTest.kt:69-81`
|
||||||
|
|
||||||
|
테스트 이름은 "receive 시 타이머 리셋"이지만 실제로는 `sendHeartBeat()` 를 직접 두 번 호출한다. 실제 경로인 `onReceivedData()` → `sendHeartBeat()` 를 통한 타이머 리셋이 검증되지 않는다. 테스트에서 `TcpClient` 또는 `HeartbeatClient` 의 `communicator.onReceivedData(...)` 를 호출하고 heartbeat가 지연되는지 확인해야 한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Nit — `Communicator.parse()` 가 public으로 노출됨**
|
||||||
|
`kotlin/src/main/kotlin/com/tokilabs/toki_socket/Communicator.kt:266`
|
||||||
|
|
||||||
|
Go 구현에서는 `parse` 가 unexported이다. Kotlin에서도 `internal` 로 제한하는 것이 적절하다. 현재 inline helper (`addRequestListenerTyped`) 는 `queuePacket`, `nextNonce` 만 필요하고 `parse` 를 직접 호출하지 않는다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 다음 단계
|
||||||
|
|
||||||
|
WARN: Suggested 항목을 수정하는 후속 PLAN.md 가 작성된다. 수정 완료 후 재리뷰.
|
||||||
254
tasks/kotlin_impl/code_review_1.log
Normal file
254
tasks/kotlin_impl/code_review_1.log
Normal file
|
|
@ -0,0 +1,254 @@
|
||||||
|
<!-- task=kotlin_impl plan=1 tag=REVIEW_API -->
|
||||||
|
|
||||||
|
# Code Review Reference - REVIEW_API
|
||||||
|
|
||||||
|
## 개요
|
||||||
|
|
||||||
|
task=kotlin_impl, plan=1, tag=REVIEW_API
|
||||||
|
|
||||||
|
## 구현 항목별 완료 여부
|
||||||
|
|
||||||
|
| 항목 | 완료 여부 |
|
||||||
|
|------|---------|
|
||||||
|
| [REVIEW_API-1] `WsServer.stop()` `InterruptedException` 미처리 수정 | [x] |
|
||||||
|
| [REVIEW_API-2] `crosstest/` 를 main sourceset에서 분리 | [x] |
|
||||||
|
| [REVIEW_API-3] `kotlin_go.kt` listener 내 `runBlocking` 제거 | [x] |
|
||||||
|
| [REVIEW_API-4] `HeartbeatTest.testHeartbeatTimerResetOnReceivedData` 테스트 추가 | [x] |
|
||||||
|
| [REVIEW_API-5] `Communicator.parse()` visibility `internal` 로 제한 | [x] |
|
||||||
|
|
||||||
|
## 계획 대비 변경 사항
|
||||||
|
|
||||||
|
- REVIEW_API-3의 listener send는 계획의 `client.scope.launch` 대신 `runTcpSendPush`/`runWsSendPush`를 `coroutineScope`로 감싸고 해당 scope의 `launch`를 사용했다. `crosstest`를 main sourceset에서 분리하면 main의 `internal` 멤버 접근이 별도 compilation에서 막힐 수 있어, crosstest runner가 main 내부 scope에 의존하지 않도록 했다.
|
||||||
|
- REVIEW_API-3 구현 중 `WsClient.forServer`도 `internal`이면 분리된 crosstest sourceset에서 `kotlin_go.kt`가 접근할 수 없다. 또한 public `WsServer` 생성자에서 기본 server-side `WsClient`를 만들 방법이 필요하므로 `WsClient.forServer`를 public companion factory로 조정했다.
|
||||||
|
- REVIEW_API-3 검증 중 분리된 crosstest sourceset에서 `parserMap()`의 `TestData.parseFrom(it)` overload 추론이 모호해져 `parserMap(): ParserMap` 반환 타입을 명시했다.
|
||||||
|
- REVIEW_API-4의 수신 경로 테스트는 `TestData` listener를 등록하고 `received` 플래그를 확인해 `onReceivedData()`가 실제 listener dispatch까지 통과했는지도 검증한다.
|
||||||
|
- 최종 검증 중 `TcpTest` class 실행이 timeout 된 뒤 메서드 단위로 분리해 확인했다. `testTcpClientCloseIdempotent`의 blocking `ServerSocket.accept()`를 `Dispatchers.IO`에서 실행하도록 바꿔 테스트 스레드 점유 위험을 낮췄고, 이후 `TcpTest`, `WsTest`, 전체 `./gradlew test`가 통과했다.
|
||||||
|
|
||||||
|
## 주요 설계 결정
|
||||||
|
|
||||||
|
- `crosstest` sourceset은 main 출력과 runtime classpath를 compile/runtime classpath로 갖도록 구성했다.
|
||||||
|
- `run` task는 `crosstest` runtime classpath에서 기본 crosstest main class를 실행하도록 재구성했다.
|
||||||
|
- `BaseClient.scope`는 계획대로 `internal`로 제한했지만, crosstest runner는 이 scope를 직접 참조하지 않는다.
|
||||||
|
- Gradle 검증은 `JAVA_HOME=/config/opt/jdk/jdk-17.0.10+7`, `GRADLE_USER_HOME=/tmp/gradle` 환경으로 실행했다.
|
||||||
|
|
||||||
|
## 리뷰어를 위한 체크포인트
|
||||||
|
|
||||||
|
- `crosstest` sourceset 분리 후 `./gradlew run` 이 여전히 `MainKt` 를 실행하는가?
|
||||||
|
- `BaseClient.scope` 를 `internal` 로 노출 시 외부 모듈에서 scope를 직접 조작하는 위험이 없는가?
|
||||||
|
- `testHeartbeatTimerResetOnReceivedData` 가 `onReceivedData` 경로를 실제로 통과하는가?
|
||||||
|
- JAR 에 crosstest 클래스가 포함되지 않는가?
|
||||||
|
|
||||||
|
## 검증 결과
|
||||||
|
|
||||||
|
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
|
||||||
|
|
||||||
|
### REVIEW_API-1 중간 검증
|
||||||
|
```
|
||||||
|
$ ./gradlew compileKotlin
|
||||||
|
> Task :checkKotlinGradlePluginConfigurationErrors SKIPPED
|
||||||
|
> Task :extractIncludeProto UP-TO-DATE
|
||||||
|
> Task :extractProto UP-TO-DATE
|
||||||
|
> Task :generateProto UP-TO-DATE
|
||||||
|
> Task :compileKotlin UP-TO-DATE
|
||||||
|
|
||||||
|
BUILD SUCCESSFUL in 13s
|
||||||
|
4 actionable tasks: 4 up-to-date
|
||||||
|
```
|
||||||
|
|
||||||
|
### REVIEW_API-2 중간 검증
|
||||||
|
```
|
||||||
|
$ ./gradlew compileKotlin compileCrosstestKotlin
|
||||||
|
> Task :checkKotlinGradlePluginConfigurationErrors SKIPPED
|
||||||
|
> Task :extractIncludeProto UP-TO-DATE
|
||||||
|
> Task :extractProto UP-TO-DATE
|
||||||
|
> Task :generateProto UP-TO-DATE
|
||||||
|
> Task :compileKotlin UP-TO-DATE
|
||||||
|
> Task :compileJava UP-TO-DATE
|
||||||
|
> Task :processResources UP-TO-DATE
|
||||||
|
> Task :classes UP-TO-DATE
|
||||||
|
> Task :extractCrosstestProto UP-TO-DATE
|
||||||
|
> Task :extractIncludeCrosstestProto UP-TO-DATE
|
||||||
|
> Task :generateCrosstestProto NO-SOURCE
|
||||||
|
> Task :compileCrosstestKotlin
|
||||||
|
|
||||||
|
BUILD SUCCESSFUL in 11s
|
||||||
|
9 actionable tasks: 1 executed, 8 up-to-date
|
||||||
|
|
||||||
|
$ ./gradlew jar
|
||||||
|
> Task :checkKotlinGradlePluginConfigurationErrors SKIPPED
|
||||||
|
> Task :extractIncludeProto UP-TO-DATE
|
||||||
|
> Task :extractProto UP-TO-DATE
|
||||||
|
> Task :generateProto UP-TO-DATE
|
||||||
|
> Task :compileKotlin UP-TO-DATE
|
||||||
|
> Task :compileJava UP-TO-DATE
|
||||||
|
> Task :processResources UP-TO-DATE
|
||||||
|
> Task :classes UP-TO-DATE
|
||||||
|
> Task :jar
|
||||||
|
|
||||||
|
BUILD SUCCESSFUL in 3s
|
||||||
|
7 actionable tasks: 1 executed, 6 up-to-date
|
||||||
|
|
||||||
|
$ jar tf build/libs/toki-socket-kotlin-0.1.0.jar | grep crosstest
|
||||||
|
(no output)
|
||||||
|
```
|
||||||
|
|
||||||
|
### REVIEW_API-3 중간 검증
|
||||||
|
```
|
||||||
|
$ ./gradlew compileCrosstestKotlin
|
||||||
|
> Task :checkKotlinGradlePluginConfigurationErrors SKIPPED
|
||||||
|
> Task :extractIncludeProto UP-TO-DATE
|
||||||
|
> Task :extractProto UP-TO-DATE
|
||||||
|
> Task :generateProto UP-TO-DATE
|
||||||
|
> Task :compileKotlin UP-TO-DATE
|
||||||
|
> Task :compileJava UP-TO-DATE
|
||||||
|
> Task :processResources UP-TO-DATE
|
||||||
|
> Task :classes UP-TO-DATE
|
||||||
|
> Task :extractCrosstestProto UP-TO-DATE
|
||||||
|
> Task :extractIncludeCrosstestProto UP-TO-DATE
|
||||||
|
> Task :generateCrosstestProto NO-SOURCE
|
||||||
|
> Task :compileCrosstestKotlin UP-TO-DATE
|
||||||
|
|
||||||
|
BUILD SUCCESSFUL in 3s
|
||||||
|
9 actionable tasks: 9 up-to-date
|
||||||
|
```
|
||||||
|
|
||||||
|
### REVIEW_API-4 중간 검증
|
||||||
|
```
|
||||||
|
$ ./gradlew test --tests "*.HeartbeatTest"
|
||||||
|
> Task :checkKotlinGradlePluginConfigurationErrors SKIPPED
|
||||||
|
> Task :extractIncludeProto UP-TO-DATE
|
||||||
|
> Task :extractProto UP-TO-DATE
|
||||||
|
> Task :generateProto UP-TO-DATE
|
||||||
|
> Task :compileKotlin UP-TO-DATE
|
||||||
|
> Task :compileJava UP-TO-DATE
|
||||||
|
> Task :processResources UP-TO-DATE
|
||||||
|
> Task :classes UP-TO-DATE
|
||||||
|
> Task :extractIncludeTestProto UP-TO-DATE
|
||||||
|
> Task :extractTestProto UP-TO-DATE
|
||||||
|
> Task :generateTestProto NO-SOURCE
|
||||||
|
> Task :compileTestKotlin UP-TO-DATE
|
||||||
|
> Task :compileTestJava NO-SOURCE
|
||||||
|
> Task :processTestResources NO-SOURCE
|
||||||
|
> Task :testClasses UP-TO-DATE
|
||||||
|
> Task :test
|
||||||
|
|
||||||
|
BUILD SUCCESSFUL in 15s
|
||||||
|
10 actionable tasks: 1 executed, 9 up-to-date
|
||||||
|
```
|
||||||
|
|
||||||
|
### REVIEW_API-5 중간 검증
|
||||||
|
```
|
||||||
|
$ ./gradlew compileKotlin compileTestKotlin
|
||||||
|
> Task :checkKotlinGradlePluginConfigurationErrors SKIPPED
|
||||||
|
> Task :extractIncludeProto UP-TO-DATE
|
||||||
|
> Task :extractProto UP-TO-DATE
|
||||||
|
> Task :generateProto UP-TO-DATE
|
||||||
|
> Task :compileKotlin UP-TO-DATE
|
||||||
|
> Task :compileJava UP-TO-DATE
|
||||||
|
> Task :processResources UP-TO-DATE
|
||||||
|
> Task :classes UP-TO-DATE
|
||||||
|
> Task :extractIncludeTestProto UP-TO-DATE
|
||||||
|
> Task :extractTestProto UP-TO-DATE
|
||||||
|
> Task :generateTestProto NO-SOURCE
|
||||||
|
> Task :compileTestKotlin
|
||||||
|
|
||||||
|
BUILD SUCCESSFUL in 8s
|
||||||
|
9 actionable tasks: 1 executed, 8 up-to-date
|
||||||
|
```
|
||||||
|
|
||||||
|
### 최종 검증
|
||||||
|
```
|
||||||
|
$ ./gradlew test
|
||||||
|
> Task :checkKotlinGradlePluginConfigurationErrors SKIPPED
|
||||||
|
> Task :extractIncludeProto UP-TO-DATE
|
||||||
|
> Task :extractProto UP-TO-DATE
|
||||||
|
> Task :generateProto UP-TO-DATE
|
||||||
|
> Task :compileKotlin UP-TO-DATE
|
||||||
|
> Task :compileJava UP-TO-DATE
|
||||||
|
> Task :processResources UP-TO-DATE
|
||||||
|
> Task :classes UP-TO-DATE
|
||||||
|
> Task :extractIncludeTestProto UP-TO-DATE
|
||||||
|
> Task :extractTestProto UP-TO-DATE
|
||||||
|
> Task :generateTestProto NO-SOURCE
|
||||||
|
> Task :compileTestKotlin UP-TO-DATE
|
||||||
|
> Task :compileTestJava NO-SOURCE
|
||||||
|
> Task :processTestResources NO-SOURCE
|
||||||
|
> Task :testClasses UP-TO-DATE
|
||||||
|
> Task :test
|
||||||
|
|
||||||
|
BUILD SUCCESSFUL in 16s
|
||||||
|
10 actionable tasks: 1 executed, 9 up-to-date
|
||||||
|
|
||||||
|
$ ./gradlew jar
|
||||||
|
> Task :checkKotlinGradlePluginConfigurationErrors SKIPPED
|
||||||
|
> Task :extractIncludeProto UP-TO-DATE
|
||||||
|
> Task :extractProto UP-TO-DATE
|
||||||
|
> Task :generateProto UP-TO-DATE
|
||||||
|
> Task :compileKotlin UP-TO-DATE
|
||||||
|
> Task :compileJava UP-TO-DATE
|
||||||
|
> Task :processResources UP-TO-DATE
|
||||||
|
> Task :classes UP-TO-DATE
|
||||||
|
> Task :jar
|
||||||
|
|
||||||
|
BUILD SUCCESSFUL in 3s
|
||||||
|
7 actionable tasks: 1 executed, 6 up-to-date
|
||||||
|
|
||||||
|
$ jar tf build/libs/toki-socket-kotlin-0.1.0.jar | grep crosstest
|
||||||
|
(no output)
|
||||||
|
|
||||||
|
$ /usr/bin/bash -lc 'PATH=/config/go-sdk/go/bin:/config/go/bin:$PATH GOCACHE=/tmp/go-build GOMODCACHE=/tmp/go-mod go test ./...'
|
||||||
|
? toki-labs.com/toki_socket/go [no test files]
|
||||||
|
? toki-labs.com/toki_socket/go/crosstest/dart_go_client [no test files]
|
||||||
|
? toki-labs.com/toki_socket/go/crosstest/kotlin_go_client [no test files]
|
||||||
|
? toki-labs.com/toki_socket/go/examples/tcp_echo [no test files]
|
||||||
|
? toki-labs.com/toki_socket/go/examples/ws_echo [no test files]
|
||||||
|
? toki-labs.com/toki_socket/go/packets [no test files]
|
||||||
|
ok toki-labs.com/toki_socket/go/test (cached)
|
||||||
|
|
||||||
|
$ bash tools/check_proto_sync.sh
|
||||||
|
Proto schemas are in sync.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 코드리뷰 결과
|
||||||
|
|
||||||
|
### 종합 판정
|
||||||
|
|
||||||
|
**PASS**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 차원별 평가
|
||||||
|
|
||||||
|
| 차원 | 판정 | 비고 |
|
||||||
|
|------|------|------|
|
||||||
|
| 정확성 (Correctness) | Pass | REVIEW_API-1~5 모두 의도대로 구현됨. 멱등성, race 보호, crosstest 분리 정상 |
|
||||||
|
| 완성도 (Completeness) | Pass | 5개 항목 전부 구현. `./gradlew test`, `jar`, Go 테스트, proto sync 통과 |
|
||||||
|
| 테스트 커버리지 (Test coverage) | Pass | `testHeartbeatTimerResetOnReceivedData` 추가. listener dispatch 경로 검증 포함 |
|
||||||
|
| API 계약 (API contract) | Pass | crosstest가 JAR에서 제외됨 확인. `parse()` internal 제한 |
|
||||||
|
| 코드 품질 (Code quality) | Pass | `runBlocking` in listener 제거, `InterruptedException` 처리 완료 |
|
||||||
|
| 계획 대비 변경 (Plan deviation) | Pass | 모든 변경이 CODE_REVIEW.md에 이유와 함께 기록됨 |
|
||||||
|
| 검증 신뢰도 (Verification trust) | Pass | JVM 환경에서 전 항목 실제 실행 확인 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 발견된 문제
|
||||||
|
|
||||||
|
**Nit — `testHeartbeatTimerResetOnReceivedData` 테스트 이름과 검증 범위 불일치**
|
||||||
|
`kotlin/src/test/kotlin/com/tokilabs/toki_socket/HeartbeatTest.kt:84-106`
|
||||||
|
|
||||||
|
테스트 이름은 "수신 데이터에 의한 타이머 리셋"이지만, 타이머를 실제로 리셋하는 것은 `onReceivedData` 이후 명시적으로 호출하는 `sendHeartBeat()`이다. `onReceivedData` 자체가 타이머를 리셋하는 경로(`TcpClient.readLoop`, `WsClient.receiveBytes`에서 `sendHeartBeat()` 호출)는 `HeartbeatClient`에서 재현되지 않는다. `received` 플래그 확인으로 listener dispatch는 검증되나, 테스트 이름이 과장된다. 블로킹 이슈는 아님.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Info — `build.gradle.kts` `application.mainClass` 중복 설정**
|
||||||
|
`kotlin/build.gradle.kts:46-60`
|
||||||
|
|
||||||
|
`application { mainClass }` 와 `tasks.named<JavaExec>("run") { mainClass }` 에 동일한 값이 중복 설정되어 있다. `tasks.named("run")`이 오버라이드하므로 `application { }` 블록의 설정은 실질적으로 무효이다. 기능상 문제는 없으나 `application { mainClass }` 제거 또는 주석 추가가 명확하다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 다음 단계
|
||||||
|
|
||||||
|
PASS: 후속 PLAN.md 없음. kotlin_impl 완료.
|
||||||
31
tasks/kotlin_impl/complete.log
Normal file
31
tasks/kotlin_impl/complete.log
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
# Task Complete — kotlin_impl
|
||||||
|
|
||||||
|
## 완료 일시
|
||||||
|
|
||||||
|
2026-04-12
|
||||||
|
|
||||||
|
## 요약
|
||||||
|
|
||||||
|
Kotlin 구현 후속 수정 (REVIEW_API) — plan=1, tag=REVIEW_API
|
||||||
|
|
||||||
|
plan-code-review 루프 2회 완료 후 PASS 판정으로 종료.
|
||||||
|
|
||||||
|
## 루프 이력
|
||||||
|
|
||||||
|
| 회차 | 계획 | 리뷰 | 판정 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| 0 | plan_0.log | code_review_0.log | WARN |
|
||||||
|
| 1 | plan_1.log | code_review_1.log | PASS |
|
||||||
|
|
||||||
|
## 최종 리뷰 요약 (plan=1)
|
||||||
|
|
||||||
|
- REVIEW_API-1: `WsServer.stop()` `InterruptedException` 미처리 → `runCatching` 으로 처리
|
||||||
|
- REVIEW_API-2: `crosstest/` 를 main sourceset에서 분리 → JAR에 crosstest 미포함 확인
|
||||||
|
- REVIEW_API-3: `kotlin_go.kt` listener 내 `runBlocking` 제거 → `coroutineScope` + `launch` 대체
|
||||||
|
- REVIEW_API-4: `testHeartbeatTimerResetOnReceivedData` 테스트 추가
|
||||||
|
- REVIEW_API-5: `Communicator.parse()` visibility `internal` 로 제한
|
||||||
|
|
||||||
|
## 잔여 Nit
|
||||||
|
|
||||||
|
- `testHeartbeatTimerResetOnReceivedData` 테스트 이름이 검증 범위를 과장 (블로킹 아님)
|
||||||
|
- `build.gradle.kts` `application.mainClass` 중복 설정 (기능 영향 없음)
|
||||||
807
tasks/kotlin_impl/plan_0.log
Normal file
807
tasks/kotlin_impl/plan_0.log
Normal file
|
|
@ -0,0 +1,807 @@
|
||||||
|
<!-- task=kotlin_impl plan=0 tag=API -->
|
||||||
|
|
||||||
|
# Kotlin 구현체 신규 추가
|
||||||
|
|
||||||
|
## 이 파일을 읽는 구현 에이전트에게
|
||||||
|
|
||||||
|
각 항목의 체크리스트를 하나씩 완료 처리하고, 중간 검증 명령을 실제로 실행한 뒤 출력을 `CODE_REVIEW.md`의 `검증 결과` 섹션에 붙여 넣으세요.
|
||||||
|
계획과 다르게 구현한 부분이 있으면 `계획 대비 변경 사항`에 이유와 함께 기록하세요.
|
||||||
|
`CODE_REVIEW.md`의 모든 섹션을 실제 구현 내용으로 채운 뒤 코드 리뷰를 요청하세요.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 배경
|
||||||
|
|
||||||
|
`PROTOCOL.md`에 기술된 Toki Socket 프로토콜의 Kotlin 구현체가 없다. Go/Dart 레퍼런스 구현체를 기준으로 동일한 wire format(TCP 4-byte big-endian framing, WebSocket binary frame), typeName 라우팅, request-response nonce 상관관계, heartbeat 자동 처리를 Kotlin coroutine 관용 패턴으로 구현한다. 구현 완료 기준은 같은 언어 단위 테스트 통과와 Go ↔ Kotlin 양방향 크로스테스트 통과이다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 의존 관계 및 구현 순서
|
||||||
|
|
||||||
|
API-1 → API-2 → API-3 → API-4 → API-5 → API-6 → API-7 → API-8
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### [API-1] Kotlin 프로젝트 구조 및 protobuf 바인딩 설정
|
||||||
|
|
||||||
|
#### 문제
|
||||||
|
|
||||||
|
`kotlin/` 디렉터리가 없고 프로젝트 파일, proto 바인딩이 없다.
|
||||||
|
|
||||||
|
#### 해결 방법
|
||||||
|
|
||||||
|
Gradle 멀티플랫폼이 아닌 순수 JVM 라이브러리 프로젝트로 설정한다. 빌드 도구는 Gradle Kotlin DSL(`build.gradle.kts`)을 사용한다.
|
||||||
|
|
||||||
|
**디렉터리 구조:**
|
||||||
|
|
||||||
|
```
|
||||||
|
kotlin/
|
||||||
|
build.gradle.kts
|
||||||
|
settings.gradle.kts
|
||||||
|
gradlew (gradle wrapper)
|
||||||
|
gradlew.bat
|
||||||
|
gradle/wrapper/
|
||||||
|
src/
|
||||||
|
main/
|
||||||
|
kotlin/com/tokilabs/toki_socket/
|
||||||
|
proto/ ← canonical proto 복사본
|
||||||
|
test/
|
||||||
|
kotlin/com/tokilabs/toki_socket/
|
||||||
|
crosstest/
|
||||||
|
go_kotlin_client/ ← Kotlin subprocess (Go 서버 ↔ Kotlin 클라이언트)
|
||||||
|
kotlin_go.kt ← Kotlin 서버 오케스트레이터 (Kotlin 서버 ↔ Go 클라이언트)
|
||||||
|
```
|
||||||
|
|
||||||
|
**`build.gradle.kts` 핵심 의존성:**
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
plugins {
|
||||||
|
kotlin("jvm") version "2.0.0"
|
||||||
|
id("com.google.protobuf") version "0.9.4"
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1")
|
||||||
|
implementation("com.google.protobuf:protobuf-kotlin:4.27.0")
|
||||||
|
implementation("com.squareup.okhttp3:okhttp:4.12.0") // WS client
|
||||||
|
implementation("org.java-websocket:Java-WebSocket:1.5.6") // WS server
|
||||||
|
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1")
|
||||||
|
testImplementation(kotlin("test"))
|
||||||
|
}
|
||||||
|
|
||||||
|
protobuf {
|
||||||
|
protoc { artifact = "com.google.protobuf:protoc:4.27.0" }
|
||||||
|
generateProtoTasks {
|
||||||
|
all().forEach { task ->
|
||||||
|
task.builtins {
|
||||||
|
id("kotlin")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**proto 동기화:**
|
||||||
|
`dart/lib/src/packets/message_common.proto`에서 `kotlin/src/main/proto/message_common.proto`로 복사. Go 처럼 언어별 option(`option java_package`, `option java_outer_classname`)을 추가할 수 있으나 **message schema는 canonical proto와 동일하게 유지**. `tools/check_proto_sync.sh`가 message 필드 diff를 검출한다.
|
||||||
|
|
||||||
|
#### 수정 파일 및 체크리스트
|
||||||
|
|
||||||
|
- [ ] `kotlin/settings.gradle.kts` 생성
|
||||||
|
- [ ] `kotlin/build.gradle.kts` 생성 (위 의존성 포함)
|
||||||
|
- [ ] Gradle wrapper 생성 (`gradle wrapper` 실행 또는 수동 배치)
|
||||||
|
- [ ] `kotlin/src/main/proto/message_common.proto` 생성 (canonical proto 복사 + java options 추가)
|
||||||
|
- [ ] `./gradlew generateProto` 실행하여 바인딩 생성 확인
|
||||||
|
|
||||||
|
#### 테스트 작성
|
||||||
|
|
||||||
|
SKIP — 이 항목은 빌드 인프라 설정이며 동작 검증은 이후 항목의 테스트로 커버된다.
|
||||||
|
|
||||||
|
#### 중간 검증
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd kotlin
|
||||||
|
./gradlew compileKotlin
|
||||||
|
# 예상: BUILD SUCCESSFUL (proto 바인딩 포함 컴파일)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### [API-2] Communicator 구현
|
||||||
|
|
||||||
|
#### 문제
|
||||||
|
|
||||||
|
Kotlin에 `Communicator`, `Transport`, `ParserMap` 타입이 없다.
|
||||||
|
|
||||||
|
#### 해결 방법
|
||||||
|
|
||||||
|
Go `communicator.go` 구조를 Kotlin coroutine 관용 패턴으로 이식한다.
|
||||||
|
|
||||||
|
**파일:** `kotlin/src/main/kotlin/com/tokilabs/toki_socket/Communicator.kt`
|
||||||
|
|
||||||
|
**핵심 매핑:**
|
||||||
|
|
||||||
|
| Go | Kotlin |
|
||||||
|
|----|--------|
|
||||||
|
| `Transport` interface | `interface Transport` |
|
||||||
|
| `ParserMap` | `typealias ParserMap = Map<String, (ByteArray) -> MessageLite>` |
|
||||||
|
| `atomic.Int32` (nonce) | `AtomicInteger` |
|
||||||
|
| `atomic.Bool` (isAlive) | `AtomicBoolean` |
|
||||||
|
| `sync.RWMutex` | `ReentrantReadWriteLock` |
|
||||||
|
| `channel queuedPacket (64)` | `Channel<QueuedPacket>(capacity = 64)` |
|
||||||
|
| `closed chan struct{}` | `Job` (coroutine cancellation) |
|
||||||
|
| `sync.Once` (closeOnce) | 없음 — `shutdown()`은 `closeOnce` 없이 AtomicBoolean + coroutine cancel |
|
||||||
|
| goroutine writeLoop | `scope.launch { writeLoop() }` |
|
||||||
|
|
||||||
|
**typeName 추출:**
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
fun typeNameOf(m: MessageLite): String =
|
||||||
|
(m as com.google.protobuf.Message).descriptorForType.fullName
|
||||||
|
```
|
||||||
|
|
||||||
|
proto에 `package` 선언이 없으므로 `fullName` = `"TestData"`, `"HeartBeat"` 등 단순 이름. PROTOCOL.md Kotlin 행과 일치.
|
||||||
|
|
||||||
|
**`addListener` / `addRequestListener` 상호 배타:**
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
fun addRequestListener(typeName: String, fn: (MessageLite, Int) -> Unit) {
|
||||||
|
val lock = rwLock.writeLock()
|
||||||
|
lock.lock()
|
||||||
|
try {
|
||||||
|
check(handlers[typeName].isNullOrEmpty()) {
|
||||||
|
"type $typeName is already registered with addListener"
|
||||||
|
}
|
||||||
|
check(!reqHandlers.containsKey(typeName)) {
|
||||||
|
"type $typeName is already registered with addRequestListener"
|
||||||
|
}
|
||||||
|
reqHandlers[typeName] = fn
|
||||||
|
} finally { lock.unlock() }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
위반 시 `IllegalStateException`(Go의 `panic` 대응).
|
||||||
|
|
||||||
|
**`writeLoop`:**
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
private suspend fun writeLoop() {
|
||||||
|
for (item in writeQueue) {
|
||||||
|
val err = runCatching { transport.writePacket(item.base) }
|
||||||
|
item.done.complete(err.exceptionOrNull())
|
||||||
|
if (err.isFailure) {
|
||||||
|
writeErrorHandler?.invoke(err.exceptionOrNull()!!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**`sendRequest`:**
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
suspend fun sendRequest(
|
||||||
|
req: MessageLite,
|
||||||
|
resTypeName: String,
|
||||||
|
timeoutMs: Long = 30_000L
|
||||||
|
): MessageLite {
|
||||||
|
...
|
||||||
|
withTimeout(timeoutMs) {
|
||||||
|
select {
|
||||||
|
pending.ch.onReceive { it }
|
||||||
|
pending.errCh.onReceive { throw it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 수정 파일 및 체크리스트
|
||||||
|
|
||||||
|
- [ ] `Communicator.kt` 생성
|
||||||
|
- [ ] `interface Transport { suspend fun writePacket(base: PacketBase); fun close() }`
|
||||||
|
- [ ] `typealias ParserMap = Map<String, (ByteArray) -> MessageLite>`
|
||||||
|
- [ ] `class Communicator(transport, parserMap, scope)` 생성자
|
||||||
|
- [ ] `initialize()`: HeartBeat 파서 자동 등록, writeLoop launch
|
||||||
|
- [ ] `isAlive(): Boolean`
|
||||||
|
- [ ] `nextNonce()`: AtomicInteger.incrementAndGet()
|
||||||
|
- [ ] `shutdown()`: isAlive=false, writeQueue.close(), scope 내부 채널 정리
|
||||||
|
- [ ] `close()`: shutdown() + transport.close()
|
||||||
|
- [ ] `queuePacket(base)`: writeQueue에 enqueue, done 대기
|
||||||
|
- [ ] `send(m)`: marshal → queuePacket
|
||||||
|
- [ ] `sendRequest(req, resTypeName, timeout)`: nonce 등록 → queuePacket → withTimeout select
|
||||||
|
- [ ] `addListener(typeName, fn)`: 상호 배타 체크
|
||||||
|
- [ ] `removeListeners(typeName)`
|
||||||
|
- [ ] `addRequestListener(typeName, fn)`: 상호 배타 체크
|
||||||
|
- [ ] `onReceivedData(typeName, data, nonce, responseNonce)`
|
||||||
|
- [ ] `handleResponse(typeName, data, responseNonce)`
|
||||||
|
- [ ] `parse(typeName, data)`
|
||||||
|
- [ ] `removePending(nonce)`
|
||||||
|
- [ ] 타입 헬퍼 함수 (Go의 제네릭 헬퍼 대응):
|
||||||
|
- [ ] `inline fun <reified T : Message> addListenerTyped(communicator, fn)`
|
||||||
|
- [ ] `inline fun <reified Req : Message, reified Res : Message> addRequestListenerTyped(communicator, fn)`
|
||||||
|
- [ ] `inline fun <reified Req : Message, reified Res : Message> sendRequestTyped(communicator, req, timeoutMs)`
|
||||||
|
|
||||||
|
#### 테스트 작성
|
||||||
|
|
||||||
|
**파일:** `kotlin/src/test/kotlin/com/tokilabs/toki_socket/CommunicatorTest.kt`
|
||||||
|
|
||||||
|
| 테스트명 | 검증 목표 |
|
||||||
|
|---------|---------|
|
||||||
|
| `testSendRequestTimeout` | timeout 경과 시 exception 발생 |
|
||||||
|
| `testSendRequestTypeMismatch` | 응답 typeName 불일치 시 exception 발생 |
|
||||||
|
| `testListenerAndRequestListenerConflict` | 동일 typeName 이중 등록 시 IllegalStateException |
|
||||||
|
| `testSendFireAndForget` | send 후 fakeTransport에 패킷 1개 기록됨 |
|
||||||
|
|
||||||
|
#### 중간 검증
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd kotlin
|
||||||
|
./gradlew test --tests "*.CommunicatorTest"
|
||||||
|
# 예상: 4개 테스트 PASS
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### [API-3] HeartbeatTimer 구현
|
||||||
|
|
||||||
|
#### 문제
|
||||||
|
|
||||||
|
주기적 heartbeat 발송을 위한 취소 가능한 타이머가 없다.
|
||||||
|
|
||||||
|
#### 해결 방법
|
||||||
|
|
||||||
|
Go의 `HeartbeatTimer`를 coroutine `delay` 기반으로 이식한다.
|
||||||
|
|
||||||
|
**파일:** `kotlin/src/main/kotlin/com/tokilabs/toki_socket/HeartbeatTimer.kt`
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
class HeartbeatTimer(
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
private val delayMs: Long,
|
||||||
|
private val callback: suspend () -> Unit
|
||||||
|
) {
|
||||||
|
private var job: Job? = null
|
||||||
|
|
||||||
|
fun reset(delayMs: Long = this.delayMs) {
|
||||||
|
job?.cancel()
|
||||||
|
job = scope.launch {
|
||||||
|
delay(delayMs)
|
||||||
|
callback()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
job?.cancel()
|
||||||
|
job = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`scope`는 `BaseClient`에서 생성된 `CoroutineScope(SupervisorJob() + Dispatchers.IO)`를 전달한다.
|
||||||
|
|
||||||
|
#### 수정 파일 및 체크리스트
|
||||||
|
|
||||||
|
- [ ] `HeartbeatTimer.kt` 생성
|
||||||
|
- [ ] `reset(delayMs)`: 기존 job cancel 후 새 delay job launch
|
||||||
|
- [ ] `stop()`: job cancel
|
||||||
|
|
||||||
|
#### 테스트 작성
|
||||||
|
|
||||||
|
**파일:** `kotlin/src/test/kotlin/com/tokilabs/toki_socket/HeartbeatTimerTest.kt`
|
||||||
|
|
||||||
|
| 테스트명 | 검증 목표 |
|
||||||
|
|---------|---------|
|
||||||
|
| `testCallbackFires` | delay 후 callback 호출됨 |
|
||||||
|
| `testStopPreventsCallback` | stop() 후 callback 미호출 |
|
||||||
|
| `testResetRestartsTimer` | reset() 후 이전 callback 미호출, 새 delay 후 호출 |
|
||||||
|
|
||||||
|
#### 중간 검증
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd kotlin
|
||||||
|
./gradlew test --tests "*.HeartbeatTimerTest"
|
||||||
|
# 예상: 3개 테스트 PASS
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### [API-4] BaseClient 구현
|
||||||
|
|
||||||
|
#### 문제
|
||||||
|
|
||||||
|
heartbeat 로직, disconnect 리스너, connCloseOnce(close-once) 공통 구조가 없다.
|
||||||
|
|
||||||
|
#### 해결 방법
|
||||||
|
|
||||||
|
Go의 `baseClient[Self]`를 `abstract class BaseClient<Self : BaseClient<Self>>`로 이식한다.
|
||||||
|
|
||||||
|
**파일:** `kotlin/src/main/kotlin/com/tokilabs/toki_socket/BaseClient.kt`
|
||||||
|
|
||||||
|
**핵심 매핑:**
|
||||||
|
|
||||||
|
| Go | Kotlin |
|
||||||
|
|----|--------|
|
||||||
|
| `self Self` | 생성자 파라미터로 `self: Self` 수신 |
|
||||||
|
| `connCloseOnce sync.Once` | `AtomicBoolean` + `compareAndSet(false, true)` |
|
||||||
|
| `hbMu sync.Mutex` | `Mutex` (kotlinx.coroutines) |
|
||||||
|
| `disconnectListeners` | `CopyOnWriteArrayList<(Self) -> Unit>` |
|
||||||
|
| `doClose func() error` | 생성자 람다 `doClose: suspend () -> Unit` |
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
abstract class BaseClient<Self : BaseClient<Self>>(
|
||||||
|
private val self: Self,
|
||||||
|
intervalSec: Int,
|
||||||
|
waitSec: Int,
|
||||||
|
private val doClose: suspend () -> Unit
|
||||||
|
) {
|
||||||
|
protected val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
abstract val communicator: Communicator
|
||||||
|
|
||||||
|
private val heartbeatIntervalMs = intervalSec * 1000L
|
||||||
|
private val heartbeatWaitMs = waitSec * 1000L
|
||||||
|
private val closedOnce = AtomicBoolean(false)
|
||||||
|
private val hbMutex = Mutex()
|
||||||
|
private var hbTimer: HeartbeatTimer? = null
|
||||||
|
private var waitingHBResponse = false
|
||||||
|
private val disconnectListeners = CopyOnWriteArrayList<(Self) -> Unit>()
|
||||||
|
|
||||||
|
fun addDisconnectListener(handler: (Self) -> Unit) { disconnectListeners.add(handler) }
|
||||||
|
fun removeDisconnectListeners() { disconnectListeners.clear() }
|
||||||
|
|
||||||
|
suspend fun close() {
|
||||||
|
if (!closedOnce.compareAndSet(false, true)) return
|
||||||
|
communicator.shutdown()
|
||||||
|
stopHeartbeat()
|
||||||
|
doClose()
|
||||||
|
notifyDisconnected()
|
||||||
|
scope.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendHeartBeat, onHeartBeat, stopHeartbeat, onDisconnected, notifyDisconnected
|
||||||
|
// Go 로직과 동일하게 구현
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`close()`는 `connCloseOnce.compareAndSet(false, true)` 패턴으로 멱등성 보장. Go의 `sync.Once`와 동일 의미.
|
||||||
|
|
||||||
|
#### 수정 파일 및 체크리스트
|
||||||
|
|
||||||
|
- [ ] `BaseClient.kt` 생성
|
||||||
|
- [ ] `scope`: `CoroutineScope(SupervisorJob() + Dispatchers.IO)`
|
||||||
|
- [ ] `close()`: compareAndSet + communicator.shutdown() + stopHeartbeat() + doClose() + notifyDisconnected() + scope.cancel()
|
||||||
|
- [ ] `sendHeartBeat()`: hbMutex 잠금, 이전 타이머 정지, 새 interval 타이머 → HeartBeat 전송 → waitingHBResponse=true → wait 타이머 → onDisconnected
|
||||||
|
- [ ] `onHeartBeat()`: waitingHBResponse 분기 처리 (Go 로직 그대로)
|
||||||
|
- [ ] `stopHeartbeat()`: hbTimer?.stop()
|
||||||
|
- [ ] `onDisconnected()`: close() 호출
|
||||||
|
- [ ] `notifyDisconnected()`: listener 복사 후 순회 호출
|
||||||
|
|
||||||
|
#### 테스트 작성
|
||||||
|
|
||||||
|
SKIP — heartbeat 통합 동작은 API-7의 HeartbeatTest에서 TcpClient를 통해 검증한다. BaseClient 자체는 추상 클래스여서 단독 단위 테스트가 어렵다.
|
||||||
|
|
||||||
|
#### 중간 검증
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd kotlin
|
||||||
|
./gradlew compileKotlin
|
||||||
|
# 예상: BUILD SUCCESSFUL (API-5 구현 전이므로 컴파일만 확인)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### [API-5] TcpClient / TcpServer 구현
|
||||||
|
|
||||||
|
#### 문제
|
||||||
|
|
||||||
|
Kotlin에 TCP transport가 없다.
|
||||||
|
|
||||||
|
#### 해결 방법
|
||||||
|
|
||||||
|
Go의 `TcpClient`, `TcpServer`를 `java.net.Socket` / `ServerSocket` + coroutine으로 이식한다.
|
||||||
|
|
||||||
|
**파일:**
|
||||||
|
- `kotlin/src/main/kotlin/com/tokilabs/toki_socket/TcpClient.kt`
|
||||||
|
- `kotlin/src/main/kotlin/com/tokilabs/toki_socket/TcpServer.kt`
|
||||||
|
|
||||||
|
**TcpClient:**
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
class TcpClient private constructor(
|
||||||
|
private val socket: Socket,
|
||||||
|
intervalSec: Int,
|
||||||
|
waitSec: Int,
|
||||||
|
) : BaseClient<TcpClient>(
|
||||||
|
self = /* 순환 참조 해결: lateinit + apply */ ...,
|
||||||
|
intervalSec = intervalSec,
|
||||||
|
waitSec = waitSec,
|
||||||
|
doClose = { socket.close() }
|
||||||
|
) {
|
||||||
|
override val communicator: Communicator = Communicator(this, parserMap, scope)
|
||||||
|
private val writeMutex = Mutex()
|
||||||
|
|
||||||
|
// TCP Transport 구현
|
||||||
|
suspend fun writePacket(base: PacketBase) {
|
||||||
|
val bytes = base.toByteArray()
|
||||||
|
val header = ByteBuffer.allocate(4).putInt(bytes.size).array()
|
||||||
|
writeMutex.withLock {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
socket.getOutputStream().write(header)
|
||||||
|
socket.getOutputStream().write(bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fun closeTransport() { socket.close() }
|
||||||
|
|
||||||
|
private fun readLoop() {
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
val input = socket.getInputStream()
|
||||||
|
val header = ByteArray(4)
|
||||||
|
while (communicator.isAlive()) {
|
||||||
|
try {
|
||||||
|
input.readFully(header)
|
||||||
|
val length = ByteBuffer.wrap(header).int
|
||||||
|
if (length == 0) continue
|
||||||
|
if (length > MAX_PACKET_SIZE) { onDisconnected(); return@launch }
|
||||||
|
val bytes = ByteArray(length)
|
||||||
|
input.readFully(bytes)
|
||||||
|
val base = PacketBase.parseFrom(bytes)
|
||||||
|
communicator.onReceivedData(base.typeName, base.data.toByteArray(), base.nonce, base.responseNonce)
|
||||||
|
sendHeartBeat()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
onDisconnected(); return@launch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`InputStream.readFully`는 `java.io.DataInputStream` wrapping으로 구현 (`readFully` extension 함수 정의).
|
||||||
|
|
||||||
|
**TcpServer:**
|
||||||
|
|
||||||
|
Go의 `TcpServer`와 동일 구조. `ServerSocket.accept()`를 `Dispatchers.IO`에서 loop.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
class TcpServer(
|
||||||
|
private val host: String,
|
||||||
|
private val port: Int,
|
||||||
|
private val newClient: (Socket) -> TcpClient,
|
||||||
|
) {
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
private val clients = CopyOnWriteArrayList<TcpClient>()
|
||||||
|
private var serverSocket: ServerSocket? = null
|
||||||
|
var onClientConnected: (TcpClient) -> Unit = {}
|
||||||
|
|
||||||
|
fun start() { ... }
|
||||||
|
fun stop() { ... }
|
||||||
|
fun broadcast(m: MessageLite) { ... }
|
||||||
|
fun clients(): List<TcpClient> { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 수정 파일 및 체크리스트
|
||||||
|
|
||||||
|
- [ ] `TcpClient.kt` 생성
|
||||||
|
- [ ] `companion object { const val MAX_PACKET_SIZE = 64 * 1024 * 1024 }`
|
||||||
|
- [ ] `Transport` 구현: `writePacket` (4-byte BE header + proto bytes), `close`
|
||||||
|
- [ ] `readLoop()`: header 4바이트 읽기 → length=0 skip → length>MAX reject → bytes 읽기 → PacketBase.parseFrom → onReceivedData → sendHeartBeat
|
||||||
|
- [ ] `DialTcp(host, port, intervalSec, waitSec, parserMap)` 팩토리 함수
|
||||||
|
- [ ] HeartBeat 리스너 등록 (`communicator.addListener(HeartBeat typeName, ::onHeartBeat)`)
|
||||||
|
- [ ] WriteErrorHandler 등록 (`communicator.setWriteErrorHandler { onDisconnected() }`)
|
||||||
|
- [ ] `TcpServer.kt` 생성
|
||||||
|
- [ ] `start()`: ServerSocket bind → accept loop (Dispatchers.IO)
|
||||||
|
- [ ] `stop()`: serverSocket.close() → clients 복사 후 전부 close
|
||||||
|
- [ ] `broadcast(m)`: clients 순회하여 send
|
||||||
|
- [ ] 클라이언트 disconnect 시 clients 리스트에서 제거 (addDisconnectListener 활용)
|
||||||
|
|
||||||
|
#### 테스트 작성
|
||||||
|
|
||||||
|
**파일:** `kotlin/src/test/kotlin/com/tokilabs/toki_socket/TcpTest.kt`
|
||||||
|
|
||||||
|
| 테스트명 | 검증 목표 |
|
||||||
|
|---------|---------|
|
||||||
|
| `testTcpSendReceive` | client.send → server addListener 수신 |
|
||||||
|
| `testTcpRequestResponse` | sendRequestTyped → index*2, "echo: msg" |
|
||||||
|
| `testTcpBroadcast` | server.broadcast → 연결된 클라이언트 수신 |
|
||||||
|
| `testTcpServerStopDisconnectsClients` | server.stop() → client disconnect 콜백 |
|
||||||
|
| `testTcpClientCloseIdempotent` | close() 3회 호출 시 오류 없음 |
|
||||||
|
|
||||||
|
#### 중간 검증
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd kotlin
|
||||||
|
./gradlew test --tests "*.TcpTest"
|
||||||
|
# 예상: 5개 테스트 PASS
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### [API-6] WsClient / WsServer 구현
|
||||||
|
|
||||||
|
#### 문제
|
||||||
|
|
||||||
|
Kotlin에 WebSocket transport가 없다.
|
||||||
|
|
||||||
|
#### 해결 방법
|
||||||
|
|
||||||
|
WebSocket 클라이언트는 OkHttp(`okhttp3.WebSocket`), 서버는 `org.java-websocket:Java-WebSocket`(`WebSocketServer`)을 사용한다. Android 호환을 위해 서버 쪽 `java-websocket`은 JVM 테스트/서버 전용으로 명시한다.
|
||||||
|
|
||||||
|
**파일:**
|
||||||
|
- `kotlin/src/main/kotlin/com/tokilabs/toki_socket/WsClient.kt`
|
||||||
|
- `kotlin/src/main/kotlin/com/tokilabs/toki_socket/WsServer.kt`
|
||||||
|
|
||||||
|
**WsClient (OkHttp 기반):**
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
class WsClient private constructor(
|
||||||
|
private val ws: okhttp3.WebSocket,
|
||||||
|
private val closeWs: () -> Unit,
|
||||||
|
intervalSec: Int,
|
||||||
|
waitSec: Int,
|
||||||
|
parserMap: ParserMap,
|
||||||
|
) : BaseClient<WsClient>(...) {
|
||||||
|
override val communicator = Communicator(this /* as Transport */, parserMap, scope)
|
||||||
|
|
||||||
|
// Transport 구현
|
||||||
|
fun writePacket(base: PacketBase) {
|
||||||
|
val bytes = base.toByteArray()
|
||||||
|
ws.send(ByteString.of(*bytes)) // OkHttp binary frame
|
||||||
|
}
|
||||||
|
|
||||||
|
// OkHttp WebSocketListener (onMessage에서 onReceivedData 호출)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
OkHttp `WebSocket.send(ByteString)`은 thread-safe하므로 별도 writeMutex 불필요.
|
||||||
|
|
||||||
|
**WsServer (Java-WebSocket 기반):**
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
class WsServer(host: String, port: Int) : org.java_websocket.server.WebSocketServer(...) {
|
||||||
|
val clients = CopyOnWriteArrayList<WsClient>()
|
||||||
|
var onClientConnected: (WsClient) -> Unit = {}
|
||||||
|
|
||||||
|
override fun onOpen(conn: WebSocket, handshake: ClientHandshake) { ... }
|
||||||
|
override fun onMessage(conn: WebSocket, bytes: ByteBuffer) { ... }
|
||||||
|
override fun onClose(...) { ... }
|
||||||
|
override fun onError(...) { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`java-websocket`의 `onMessage(conn, ByteBuffer)`로 binary frame을 수신하여 `PacketBase.parseFrom(bytes)` 후 해당 WsClient의 `communicator.onReceivedData(...)` 호출.
|
||||||
|
|
||||||
|
#### 수정 파일 및 체크리스트
|
||||||
|
|
||||||
|
- [ ] `WsClient.kt` 생성
|
||||||
|
- [ ] OkHttpClient + `Request.Builder().url(ws://...)` + `newWebSocket(request, listener)`
|
||||||
|
- [ ] `WebSocketListener.onMessage(ws, bytes: ByteString)`: PacketBase.parseFrom → onReceivedData → sendHeartBeat
|
||||||
|
- [ ] `WebSocketListener.onFailure(ws, t, response)`: onDisconnected
|
||||||
|
- [ ] `Transport.writePacket`: `ws.send(ByteString.of(*bytes))`
|
||||||
|
- [ ] `Transport.close`: `ws.close(1000, null)` + OkHttpClient.dispatcher.executorService.shutdown()
|
||||||
|
- [ ] HeartBeat 리스너 등록, WriteErrorHandler 등록
|
||||||
|
- [ ] `DialWs(host, port, path, intervalSec, waitSec, parserMap)` 팩토리
|
||||||
|
- [ ] `DialWss(host, port, path, sslContext, intervalSec, waitSec, parserMap)` 팩토리
|
||||||
|
- [ ] `WsServer.kt` 생성
|
||||||
|
- [ ] `WebSocketServer` 상속, binary 프레임 처리
|
||||||
|
- [ ] WsClient 인스턴스 생성 및 clients 리스트 관리
|
||||||
|
- [ ] `start()` / `stop()` / `broadcast(m)`
|
||||||
|
- [ ] 클라이언트 disconnect 시 clients 리스트에서 제거
|
||||||
|
|
||||||
|
#### 테스트 작성
|
||||||
|
|
||||||
|
**파일:** `kotlin/src/test/kotlin/com/tokilabs/toki_socket/WsTest.kt`
|
||||||
|
|
||||||
|
| 테스트명 | 검증 목표 |
|
||||||
|
|---------|---------|
|
||||||
|
| `testWsSendReceive` | client.send → server addListener 수신 |
|
||||||
|
| `testWsRequestResponse` | sendRequestTyped → index*2, "echo: msg" |
|
||||||
|
| `testWsBroadcast` | server.broadcast → 연결된 클라이언트 수신 |
|
||||||
|
| `testWsServerStopDisconnectsClients` | server.stop() → client disconnect 콜백 |
|
||||||
|
|
||||||
|
#### 중간 검증
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd kotlin
|
||||||
|
./gradlew test --tests "*.WsTest"
|
||||||
|
# 예상: 4개 테스트 PASS
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### [API-7] Heartbeat 통합 테스트
|
||||||
|
|
||||||
|
#### 문제
|
||||||
|
|
||||||
|
heartbeat 타이머가 비활성 구간 후 올바르게 발송되고, 응답 없을 때 disconnect를 트리거하는지 검증되지 않았다.
|
||||||
|
|
||||||
|
#### 해결 방법
|
||||||
|
|
||||||
|
Go의 `heartbeat_test.go` 패턴을 Kotlin coroutine 테스트로 이식한다. `runTest` + `TestCoroutineScheduler`로 실제 시간 대기 없이 가상 시간 진행.
|
||||||
|
|
||||||
|
**파일:** `kotlin/src/test/kotlin/com/tokilabs/toki_socket/HeartbeatTest.kt`
|
||||||
|
|
||||||
|
| 테스트명 | 검증 목표 |
|
||||||
|
|---------|---------|
|
||||||
|
| `testHeartbeatSentAfterInactivity` | intervalSec 경과 → HeartBeat 패킷 전송 확인 |
|
||||||
|
| `testHeartbeatDisconnectOnNoResponse` | waitSec 경과 → onDisconnected 호출 확인 |
|
||||||
|
| `testHeartbeatResetOnReceive` | 메시지 수신 → heartbeat 타이머 리셋 확인 |
|
||||||
|
|
||||||
|
#### 수정 파일 및 체크리스트
|
||||||
|
|
||||||
|
- [ ] `HeartbeatTest.kt` 생성 (위 3개 테스트)
|
||||||
|
|
||||||
|
#### 테스트 작성
|
||||||
|
|
||||||
|
본 항목 자체가 테스트 작성이다.
|
||||||
|
|
||||||
|
#### 중간 검증
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd kotlin
|
||||||
|
./gradlew test --tests "*.HeartbeatTest"
|
||||||
|
# 예상: 3개 테스트 PASS
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### [API-8] Go ↔ Kotlin 크로스테스트
|
||||||
|
|
||||||
|
#### 문제
|
||||||
|
|
||||||
|
다른 언어 구현체와의 wire format 호환성이 검증되지 않았다.
|
||||||
|
|
||||||
|
#### 해결 방법
|
||||||
|
|
||||||
|
`skills/add-toki-socket-crosstest-language/SKILL.md`의 runner 배치 규칙을 따른다.
|
||||||
|
|
||||||
|
**추가 파일:**
|
||||||
|
|
||||||
|
| 파일 | 역할 |
|
||||||
|
|------|------|
|
||||||
|
| `go/crosstest/go_kotlin.go` | Go 서버 오케스트레이터 (Kotlin subprocess 실행) |
|
||||||
|
| `kotlin/crosstest/go_kotlin_client/Main.kt` | Kotlin subprocess (Go 서버에 접속) |
|
||||||
|
| `kotlin/crosstest/kotlin_go.kt` | Kotlin 서버 오케스트레이터 (Go subprocess 실행) |
|
||||||
|
| `go/crosstest/kotlin_go_client/main.go` | Go subprocess (Kotlin 서버에 접속) |
|
||||||
|
|
||||||
|
**포트 배정 (기존과 충돌 없음):**
|
||||||
|
|
||||||
|
```
|
||||||
|
Go server / Kotlin client TCP: 29290
|
||||||
|
Go server / Kotlin client WS: 29292
|
||||||
|
Kotlin server / Go client TCP: 29390
|
||||||
|
Kotlin server / Go client WS: 29392
|
||||||
|
```
|
||||||
|
|
||||||
|
기존 포트 (충돌 없음 확인):
|
||||||
|
- 29090 (Dart server / Go client TCP)
|
||||||
|
- 29092 (Dart server / Go client WS)
|
||||||
|
- 29190 (Go server / Dart client TCP)
|
||||||
|
- 29192 (Go server / Dart client WS)
|
||||||
|
|
||||||
|
**시나리오 (TCP + WebSocket 각각):**
|
||||||
|
|
||||||
|
| 시나리오 | 내용 |
|
||||||
|
|---------|------|
|
||||||
|
| 1 | 클라이언트 fire-and-forget `TestData(index=101, message="fire from kotlin client")` → 서버 검증 |
|
||||||
|
| 2 | 서버 push `TestData(index=200, message="push from go server")` → 클라이언트 검증 |
|
||||||
|
| 3 | 클라이언트 `sendRequest` → 응답 `index=req.index*2`, `message="echo: req.message"` |
|
||||||
|
| 4 | 동시 5개 `sendRequest` → responseNonce 라우팅 검증 |
|
||||||
|
|
||||||
|
send-push/requests 페이즈 분리 패턴은 Go ↔ Dart 크로스테스트와 동일하게 적용한다.
|
||||||
|
|
||||||
|
**`kotlin/crosstest/go_kotlin_client/Main.kt` 실행 방법:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd kotlin
|
||||||
|
./gradlew run --args="--mode=tcp --port=29290 --phase=send-push"
|
||||||
|
```
|
||||||
|
|
||||||
|
`build.gradle.kts`에 `application { mainClass.set("com.tokilabs.toki_socket.crosstest.MainKt") }` 추가 필요.
|
||||||
|
|
||||||
|
**`go/crosstest/go_kotlin.go` 실행 방법:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd go
|
||||||
|
go run ./crosstest/go_kotlin.go
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 수정 파일 및 체크리스트
|
||||||
|
|
||||||
|
- [ ] `go/crosstest/go_kotlin.go` 생성
|
||||||
|
- [ ] TCP send-push (port 29290), TCP requests, WS send-push (port 29292), WS requests
|
||||||
|
- [ ] Kotlin subprocess 실행: `./gradlew run --args="..."` (kotlin dir 기준)
|
||||||
|
- [ ] `validateResultLines` 활용 (go_dart.go와 동일 헬퍼)
|
||||||
|
- [ ] `kotlin/crosstest/go_kotlin_client/Main.kt` 생성
|
||||||
|
- [ ] `INFO typeName kotlin=TestData` 출력
|
||||||
|
- [ ] `--mode`, `--port`, `--phase` 인수 파싱
|
||||||
|
- [ ] send-push: TestData 전송 (시나리오 1) + 서버 push 수신 (시나리오 2)
|
||||||
|
- [ ] requests: 단일 request (시나리오 3) + 5개 concurrent request (시나리오 4)
|
||||||
|
- [ ] `PASS scenario=N detail=...` / `FAIL scenario=N error=...` 출력
|
||||||
|
- [ ] `kotlin/crosstest/kotlin_go.kt` 생성 (Kotlin 서버 오케스트레이터)
|
||||||
|
- [ ] TCP 서버 (29390) + WS 서버 (29392) 시작
|
||||||
|
- [ ] Go subprocess 실행: `go run ./crosstest/kotlin_go_client`
|
||||||
|
- [ ] PASS/FAIL 파싱 및 검증
|
||||||
|
- [ ] `go/crosstest/kotlin_go_client/main.go` 생성
|
||||||
|
- [ ] Kotlin 서버에 접속하는 Go 클라이언트 (dart_go_client/main.go와 동일 구조)
|
||||||
|
- [ ] `INFO typeName go=TestData` 출력
|
||||||
|
- [ ] 동일 4개 시나리오
|
||||||
|
|
||||||
|
#### 테스트 작성
|
||||||
|
|
||||||
|
본 항목 자체가 크로스테스트이다.
|
||||||
|
|
||||||
|
#### 중간 검증
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Go 서버 ↔ Kotlin 클라이언트
|
||||||
|
cd go && go run ./crosstest/go_kotlin.go
|
||||||
|
# 예상: PASS all go-server/kotlin-client crosstests passed
|
||||||
|
|
||||||
|
# Kotlin 서버 ↔ Go 클라이언트
|
||||||
|
cd kotlin && ./gradlew run -PmainClass=com.tokilabs.toki_socket.crosstest.KotlinGoKt
|
||||||
|
# 또는
|
||||||
|
cd kotlin && kotlinc -script crosstest/kotlin_go.kts
|
||||||
|
# 예상: PASS all kotlin-server/go-client crosstests passed
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 수정 파일 요약
|
||||||
|
|
||||||
|
| 파일 | 항목 |
|
||||||
|
|------|------|
|
||||||
|
| `kotlin/settings.gradle.kts` | API-1 |
|
||||||
|
| `kotlin/build.gradle.kts` | API-1 |
|
||||||
|
| `kotlin/src/main/proto/message_common.proto` | API-1 |
|
||||||
|
| `kotlin/src/main/kotlin/.../Communicator.kt` | API-2 |
|
||||||
|
| `kotlin/src/test/kotlin/.../CommunicatorTest.kt` | API-2 |
|
||||||
|
| `kotlin/src/main/kotlin/.../HeartbeatTimer.kt` | API-3 |
|
||||||
|
| `kotlin/src/test/kotlin/.../HeartbeatTimerTest.kt` | API-3 |
|
||||||
|
| `kotlin/src/main/kotlin/.../BaseClient.kt` | API-4 |
|
||||||
|
| `kotlin/src/main/kotlin/.../TcpClient.kt` | API-5 |
|
||||||
|
| `kotlin/src/main/kotlin/.../TcpServer.kt` | API-5 |
|
||||||
|
| `kotlin/src/test/kotlin/.../TcpTest.kt` | API-5 |
|
||||||
|
| `kotlin/src/main/kotlin/.../WsClient.kt` | API-6 |
|
||||||
|
| `kotlin/src/main/kotlin/.../WsServer.kt` | API-6 |
|
||||||
|
| `kotlin/src/test/kotlin/.../WsTest.kt` | API-6 |
|
||||||
|
| `kotlin/src/test/kotlin/.../HeartbeatTest.kt` | API-7 |
|
||||||
|
| `go/crosstest/go_kotlin.go` | API-8 |
|
||||||
|
| `kotlin/crosstest/go_kotlin_client/Main.kt` | API-8 |
|
||||||
|
| `kotlin/crosstest/kotlin_go.kt` | API-8 |
|
||||||
|
| `go/crosstest/kotlin_go_client/main.go` | API-8 |
|
||||||
|
| `PROTOCOL.md` | Kotlin 행 status 업데이트 |
|
||||||
|
| `README.md` | Kotlin 행 status 업데이트 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 최종 검증
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Kotlin 단위 테스트 전체
|
||||||
|
cd kotlin
|
||||||
|
./gradlew test
|
||||||
|
# 예상: 모든 테스트 PASS, BUILD SUCCESSFUL
|
||||||
|
|
||||||
|
# 2. Go 단위 테스트 (크로스테스트 헬퍼 추가로 인한 회귀 없음)
|
||||||
|
cd go
|
||||||
|
go test ./...
|
||||||
|
# 예상: ok toki-labs.com/toki_socket/go [no test files changed]
|
||||||
|
|
||||||
|
# 3. Kotlin linter
|
||||||
|
cd kotlin
|
||||||
|
./gradlew ktlintCheck # ktlint plugin 추가 시
|
||||||
|
# 예상: BUILD SUCCESSFUL
|
||||||
|
|
||||||
|
# 4. Go 서버 ↔ Kotlin 클라이언트 크로스테스트
|
||||||
|
cd go
|
||||||
|
go run ./crosstest/go_kotlin.go
|
||||||
|
# 예상: PASS all go-server/kotlin-client crosstests passed
|
||||||
|
|
||||||
|
# 5. Kotlin 서버 ↔ Go 클라이언트 크로스테스트
|
||||||
|
cd kotlin
|
||||||
|
./gradlew run -PmainClass=com.tokilabs.toki_socket.crosstest.KotlinGoKt
|
||||||
|
# 예상: PASS all kotlin-server/go-client crosstests passed
|
||||||
|
|
||||||
|
# 6. proto 동기화 검증
|
||||||
|
tools/check_proto_sync.sh
|
||||||
|
# 예상: no diff
|
||||||
|
```
|
||||||
396
tasks/kotlin_impl/plan_1.log
Normal file
396
tasks/kotlin_impl/plan_1.log
Normal file
|
|
@ -0,0 +1,396 @@
|
||||||
|
<!-- task=kotlin_impl plan=1 tag=REVIEW_API -->
|
||||||
|
|
||||||
|
# Kotlin 구현 후속 수정 — WARN 항목 해소
|
||||||
|
|
||||||
|
## 이 파일을 읽는 구현 에이전트에게
|
||||||
|
|
||||||
|
각 항목의 체크리스트를 완료 처리하고, 중간 검증 명령을 실제로 실행한 뒤 출력을 `CODE_REVIEW.md`의 `검증 결과` 섹션에 붙여 넣으세요.
|
||||||
|
계획과 다르게 구현한 부분이 있으면 `계획 대비 변경 사항`에 이유와 함께 기록하세요.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 배경
|
||||||
|
|
||||||
|
`plan_0.log` 의 WARN 판정에서 발견된 4개 Suggested 항목을 수정한다. Required 항목은 없었고, Nit 1개(`Communicator.parse()` visibility)는 아래 포함한다. JVM 환경 부재로 인한 Gradle 미검증 상태는 이 항목들과 무관하므로 별도 다루지 않는다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 의존 관계 및 구현 순서
|
||||||
|
|
||||||
|
REVIEW_API-1 → REVIEW_API-2 (sourceset 변경이 선행돼야 kotlin 빌드 구조에서 REVIEW_API-3, 4 검증 가능) → REVIEW_API-3, REVIEW_API-4 (병렬 가능)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### [REVIEW_API-1] `WsServer.stop()` — `InterruptedException` 미처리 수정
|
||||||
|
|
||||||
|
#### 문제
|
||||||
|
|
||||||
|
`kotlin/src/main/kotlin/com/tokilabs/toki_socket/WsServer.kt:41`
|
||||||
|
|
||||||
|
`super.stop(1000)` 은 `WebSocketServer.stop(int)` 로, Java 검사 예외 `InterruptedException` 을 선언한다. `runBlocking` 내에서 호출될 때 이 예외가 전파되면 현재 coroutine이 취소 신호로 처리하여 `stop()` 이후 정리 코드가 실행되지 않을 수 있다.
|
||||||
|
|
||||||
|
#### 해결 방법
|
||||||
|
|
||||||
|
`super.stop(1000)` 을 `runCatching` 으로 감싼다.
|
||||||
|
|
||||||
|
**Before (`WsServer.kt:36-41`):**
|
||||||
|
```kotlin
|
||||||
|
override fun stop() {
|
||||||
|
if (!startedFlag.compareAndSet(true, false)) return
|
||||||
|
val snapshot = clients.toList()
|
||||||
|
clients.clear()
|
||||||
|
snapshot.forEach { it.close() }
|
||||||
|
super.stop(1000)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**After:**
|
||||||
|
```kotlin
|
||||||
|
override fun stop() {
|
||||||
|
if (!startedFlag.compareAndSet(true, false)) return
|
||||||
|
val snapshot = clients.toList()
|
||||||
|
clients.clear()
|
||||||
|
snapshot.forEach { it.close() }
|
||||||
|
runCatching { super.stop(1000) }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 수정 파일 및 체크리스트
|
||||||
|
|
||||||
|
- [x] `kotlin/src/main/kotlin/com/tokilabs/toki_socket/WsServer.kt` — `super.stop(1000)` → `runCatching { super.stop(1000) }`
|
||||||
|
|
||||||
|
#### 테스트 작성
|
||||||
|
|
||||||
|
SKIP — 기존 `WsTest.testWsServerStopDisconnectsClients` 가 stop 경로를 커버한다. `InterruptedException` 을 강제로 발생시키는 단위 테스트는 `java-websocket` 내부 구현 의존성이 높아 추가하지 않는다.
|
||||||
|
|
||||||
|
#### 중간 검증
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd kotlin
|
||||||
|
./gradlew compileKotlin
|
||||||
|
# 예상: BUILD SUCCESSFUL
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### [REVIEW_API-2] `crosstest/` 를 main sourceset에서 분리
|
||||||
|
|
||||||
|
#### 문제
|
||||||
|
|
||||||
|
`kotlin/build.gradle.kts:14`
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
named("main") {
|
||||||
|
kotlin.srcDir("crosstest")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`kotlin_go.kt`, `go_kotlin_client/Main.kt` 가 라이브러리 main sourceset에 포함되어 프로덕션 JAR에 crosstest 코드가 실린다.
|
||||||
|
|
||||||
|
#### 해결 방법
|
||||||
|
|
||||||
|
`build.gradle.kts` 에서 `crosstest` 를 sourceSets.main 에서 제거하고, 대신 별도 `crosstest` sourceset 을 정의한다. `application` 플러그인의 `mainClass` 는 기본값을 크로스테스트 runner로 유지하되 `crosstest` sourceset을 통해 공급한다.
|
||||||
|
|
||||||
|
**Before (`build.gradle.kts:10-17`):**
|
||||||
|
```kotlin
|
||||||
|
kotlin {
|
||||||
|
jvmToolchain(17)
|
||||||
|
sourceSets {
|
||||||
|
named("main") {
|
||||||
|
kotlin.srcDir("crosstest")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**After:**
|
||||||
|
```kotlin
|
||||||
|
kotlin {
|
||||||
|
jvmToolchain(17)
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceSets {
|
||||||
|
create("crosstest") {
|
||||||
|
kotlin.srcDir("crosstest")
|
||||||
|
compileClasspath += sourceSets["main"].output + configurations["runtimeClasspath"]
|
||||||
|
runtimeClasspath += output + compileClasspath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`application` 블록의 `mainClass` 는 `crosstest` sourceset의 classpath 에서 로드되도록 `run` task를 재구성한다.
|
||||||
|
|
||||||
|
**Before (`build.gradle.kts:42-47`):**
|
||||||
|
```kotlin
|
||||||
|
application {
|
||||||
|
mainClass.set(
|
||||||
|
(findProperty("mainClass") as String?)
|
||||||
|
?: "com.tokilabs.toki_socket.crosstest.MainKt",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**After:**
|
||||||
|
```kotlin
|
||||||
|
val crosstestSourceSet = sourceSets["crosstest"]
|
||||||
|
|
||||||
|
tasks.named<JavaExec>("run") {
|
||||||
|
classpath = crosstestSourceSet.runtimeClasspath
|
||||||
|
mainClass.set(
|
||||||
|
(findProperty("mainClass") as String?)
|
||||||
|
?: "com.tokilabs.toki_socket.crosstest.MainKt",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 수정 파일 및 체크리스트
|
||||||
|
|
||||||
|
- [x] `kotlin/build.gradle.kts` — `sourceSets.main.kotlin.srcDir("crosstest")` 제거
|
||||||
|
- [x] `kotlin/build.gradle.kts` — `crosstest` sourceset 정의 추가
|
||||||
|
- [x] `kotlin/build.gradle.kts` — `tasks.named<JavaExec>("run")` 블록에서 classpath를 `crosstestSourceSet.runtimeClasspath` 로 지정
|
||||||
|
- [x] `kotlin/build.gradle.kts` — `kotlin { sourceSets { named("main") { ... } } }` 블록 제거 또는 빈 상태로 정리
|
||||||
|
|
||||||
|
#### 테스트 작성
|
||||||
|
|
||||||
|
SKIP — 빌드 구조 변경이므로 컴파일 성공으로 충분하다.
|
||||||
|
|
||||||
|
#### 중간 검증
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd kotlin
|
||||||
|
./gradlew compileKotlin compileCrosstestKotlin
|
||||||
|
# 예상: BUILD SUCCESSFUL
|
||||||
|
# crosstest 클래스가 main JAR에 포함되지 않는지 확인:
|
||||||
|
./gradlew jar
|
||||||
|
jar tf build/libs/toki-socket-kotlin-0.1.0.jar | grep crosstest
|
||||||
|
# 예상: 출력 없음 (crosstest 클래스 미포함)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### [REVIEW_API-3] `kotlin_go.kt` listener 내 `runBlocking` 제거
|
||||||
|
|
||||||
|
#### 문제
|
||||||
|
|
||||||
|
`kotlin/crosstest/kotlin_go.kt:56-63`, `104-111`
|
||||||
|
|
||||||
|
`addListenerTyped` 의 callback은 `TcpClient.readLoop` 의 `Dispatchers.IO` coroutine에서 동기 호출된다. 여기서 `runBlocking { client.send(...) }` 를 호출하면 IO 스레드를 블로킹한다.
|
||||||
|
|
||||||
|
#### 해결 방법
|
||||||
|
|
||||||
|
`runBlocking { ... }` 을 `scope.launch { ... }` 로 교체한다. `kotlin_go.kt` 파일은 `runBlocking` scope 안에서 실행되므로, 서버 scope를 직접 사용하거나 `GlobalScope` 대신 로컬 `CoroutineScope` 를 생성한다. 가장 단순한 방법은 listener에서 별도 coroutine을 시작한다.
|
||||||
|
|
||||||
|
**Before (`kotlin_go.kt:51-63`):**
|
||||||
|
```kotlin
|
||||||
|
server.onClientConnected = { client ->
|
||||||
|
addListenerTyped<TestData>(client.communicator) { data ->
|
||||||
|
println("SERVER_RECEIVED index=${data.index} message=${data.message}")
|
||||||
|
val valid = data.index == 101 && data.message == "fire from go client"
|
||||||
|
received.complete(valid)
|
||||||
|
if (valid) {
|
||||||
|
runBlocking {
|
||||||
|
client.send(
|
||||||
|
TestData.newBuilder()
|
||||||
|
.setIndex(200)
|
||||||
|
.setMessage("push from kotlin server")
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**After:**
|
||||||
|
```kotlin
|
||||||
|
server.onClientConnected = { client ->
|
||||||
|
addListenerTyped<TestData>(client.communicator) { data ->
|
||||||
|
println("SERVER_RECEIVED index=${data.index} message=${data.message}")
|
||||||
|
val valid = data.index == 101 && data.message == "fire from go client"
|
||||||
|
received.complete(valid)
|
||||||
|
if (valid) {
|
||||||
|
client.communicator.scope.launch {
|
||||||
|
client.send(
|
||||||
|
TestData.newBuilder()
|
||||||
|
.setIndex(200)
|
||||||
|
.setMessage("push from kotlin server")
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
단, `Communicator.scope` 가 현재 `private` 이다. `BaseClient.scope` 가 `protected` 이므로, `kotlin_go.kt` 에서 접근 가능한 방법이 필요하다. `TcpClient` 가 `BaseClient` 를 상속하므로 `client.scope` (protected)를 `internal` 또는 직접 접근 가능한 방식으로 노출하거나, `client.communicator` 를 통해 접근하는 대신 `kotlinx.coroutines.GlobalScope` 를 임시 사용한다.
|
||||||
|
|
||||||
|
가장 깔끔한 방법: `BaseClient` 의 `scope` 를 `internal` 로 노출하여 같은 모듈 내 crosstest 코드에서 사용 가능하게 한다.
|
||||||
|
|
||||||
|
**`BaseClient.kt` 변경:**
|
||||||
|
```kotlin
|
||||||
|
// Before
|
||||||
|
protected val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
|
||||||
|
// After
|
||||||
|
internal val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
```
|
||||||
|
|
||||||
|
`kotlin_go.kt` 의 `runBlocking { client.send(...) }` 두 곳 (TCP send-push:56-63, WS send-push:104-111) 을 `client.scope.launch { client.send(...) }` 로 교체한다.
|
||||||
|
|
||||||
|
#### 수정 파일 및 체크리스트
|
||||||
|
|
||||||
|
- [x] `kotlin/src/main/kotlin/com/tokilabs/toki_socket/BaseClient.kt` — `scope` 를 `protected` → `internal` 로 변경
|
||||||
|
- [x] `kotlin/crosstest/kotlin_go.kt:56-63` — `runBlocking { client.send(...) }` → `coroutineScope` 의 `launch { client.send(...) }`
|
||||||
|
- [x] `kotlin/crosstest/kotlin_go.kt:104-111` — 동일 변경
|
||||||
|
|
||||||
|
#### 테스트 작성
|
||||||
|
|
||||||
|
SKIP — crosstest 코드 수정이고 기능 변경이 없다. 크로스테스트 자체가 검증이다.
|
||||||
|
|
||||||
|
#### 중간 검증
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd kotlin
|
||||||
|
./gradlew compileCrosstestKotlin
|
||||||
|
# 예상: BUILD SUCCESSFUL (runBlocking 제거로 인한 compile error 없음)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### [REVIEW_API-4] `HeartbeatTest.testHeartbeatResetOnReceive()` — 수신 경로 검증 추가
|
||||||
|
|
||||||
|
#### 문제
|
||||||
|
|
||||||
|
`kotlin/src/test/kotlin/com/tokilabs/toki_socket/HeartbeatTest.kt:69-81`
|
||||||
|
|
||||||
|
현재 테스트는 `sendHeartBeat()` 를 두 번 직접 호출한다. 실제 경로인 `communicator.onReceivedData()` → `sendHeartBeat()` 를 거쳐 타이머가 리셋되는지 검증하지 않는다.
|
||||||
|
|
||||||
|
#### 해결 방법
|
||||||
|
|
||||||
|
기존 테스트를 두 개로 분리한다:
|
||||||
|
1. `testHeartbeatTimerResetBySendHeartBeat` — 기존 로직 유지 (sendHeartBeat 재호출 → 타이머 리셋)
|
||||||
|
2. `testHeartbeatTimerResetOnReceivedData` — `communicator.onReceivedData()` 를 호출한 뒤 heartbeat가 지연되는지 확인
|
||||||
|
|
||||||
|
**새 테스트 (`testHeartbeatTimerResetOnReceivedData`):**
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Test
|
||||||
|
fun testHeartbeatTimerResetOnReceivedData() = runBlocking {
|
||||||
|
val transport = HeartbeatTransport()
|
||||||
|
val client = HeartbeatClient(transport, 1, 1)
|
||||||
|
var received = false
|
||||||
|
client.communicator.addListener(typeNameOf(testData())) {
|
||||||
|
received = true
|
||||||
|
}
|
||||||
|
|
||||||
|
client.sendHeartBeat()
|
||||||
|
delay(700)
|
||||||
|
// 데이터 수신 시뮬레이션 — readLoop가 onReceivedData 후 sendHeartBeat() 호출하는 경로
|
||||||
|
client.communicator.onReceivedData(typeNameOf(testData()), testData().toByteArray(), incomingNonce = 1)
|
||||||
|
assertTrue(received)
|
||||||
|
client.sendHeartBeat() // readLoop가 호출하는 sendHeartBeat()
|
||||||
|
delay(500)
|
||||||
|
assertTrue(transport.packets.none { it.typeName == typeNameOf<HeartBeat>() })
|
||||||
|
delay(700)
|
||||||
|
assertTrue(transport.packets.any { it.typeName == typeNameOf<HeartBeat>() })
|
||||||
|
client.close()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`testData()` 헬퍼: `TestHelpers.kt` 에 `fun testData() = TestData.newBuilder().setIndex(1).setMessage("ping").build()` 추가.
|
||||||
|
|
||||||
|
`HeartbeatClient` 의 `communicator` parserMap 에 `TestData` 가 포함되도록 `testParserMap()` 을 사용한다. 테스트에서는 `TestData` 리스너를 등록하고 `received` 를 확인해 `onReceivedData()` 가 listener dispatch까지 실제로 진행됐는지 검증한다.
|
||||||
|
|
||||||
|
#### 수정 파일 및 체크리스트
|
||||||
|
|
||||||
|
- [x] `kotlin/src/test/kotlin/com/tokilabs/toki_socket/HeartbeatTest.kt` — `testHeartbeatResetOnReceive` 를 `testHeartbeatTimerResetBySendHeartBeat` 로 rename
|
||||||
|
- [x] `kotlin/src/test/kotlin/com/tokilabs/toki_socket/HeartbeatTest.kt` — `testHeartbeatTimerResetOnReceivedData` 테스트 추가
|
||||||
|
- [x] `kotlin/src/test/kotlin/com/tokilabs/toki_socket/TestHelpers.kt` — `fun testData()` 헬퍼 추가
|
||||||
|
|
||||||
|
#### 테스트 작성
|
||||||
|
|
||||||
|
본 항목이 테스트 추가이다.
|
||||||
|
|
||||||
|
#### 중간 검증
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd kotlin
|
||||||
|
./gradlew test --tests "*.HeartbeatTest"
|
||||||
|
# 예상: 4개 테스트 PASS (기존 3개 + 신규 1개)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### [REVIEW_API-5] `Communicator.parse()` visibility — `internal` 로 제한 (Nit)
|
||||||
|
|
||||||
|
#### 문제
|
||||||
|
|
||||||
|
`kotlin/src/main/kotlin/com/tokilabs/toki_socket/Communicator.kt:266`
|
||||||
|
|
||||||
|
`parse()` 가 `public` 으로 노출되어 있다. Go 의 `parse` 는 unexported이다. inline helper들은 `parse()` 를 직접 호출하지 않는다.
|
||||||
|
|
||||||
|
#### 해결 방법
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// Before
|
||||||
|
fun parse(typeName: String, data: ByteArray): MessageLite {
|
||||||
|
|
||||||
|
// After
|
||||||
|
internal fun parse(typeName: String, data: ByteArray): MessageLite {
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 수정 파일 및 체크리스트
|
||||||
|
|
||||||
|
- [x] `kotlin/src/main/kotlin/com/tokilabs/toki_socket/Communicator.kt:266` — `fun parse` → `internal fun parse`
|
||||||
|
|
||||||
|
#### 테스트 작성
|
||||||
|
|
||||||
|
SKIP — `CommunicatorTest` 는 `parse()` 를 직접 호출하지 않는다. 같은 모듈이므로 `internal` 후에도 테스트 접근 가능하다.
|
||||||
|
|
||||||
|
#### 중간 검증
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd kotlin
|
||||||
|
./gradlew compileKotlin compileTestKotlin
|
||||||
|
# 예상: BUILD SUCCESSFUL
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 수정 파일 요약
|
||||||
|
|
||||||
|
| 파일 | 항목 |
|
||||||
|
|------|------|
|
||||||
|
| `kotlin/src/main/kotlin/com/tokilabs/toki_socket/WsServer.kt` | REVIEW_API-1 |
|
||||||
|
| `kotlin/build.gradle.kts` | REVIEW_API-2 |
|
||||||
|
| `kotlin/src/main/kotlin/com/tokilabs/toki_socket/BaseClient.kt` | REVIEW_API-3 |
|
||||||
|
| `kotlin/crosstest/kotlin_go.kt` | REVIEW_API-3 |
|
||||||
|
| `kotlin/src/main/kotlin/com/tokilabs/toki_socket/WsClient.kt` | REVIEW_API-3 follow-up: crosstest sourceset에서 server factory 접근 가능하도록 공개 |
|
||||||
|
| `kotlin/src/test/kotlin/com/tokilabs/toki_socket/HeartbeatTest.kt` | REVIEW_API-4 |
|
||||||
|
| `kotlin/src/test/kotlin/com/tokilabs/toki_socket/TestHelpers.kt` | REVIEW_API-4 |
|
||||||
|
| `kotlin/src/test/kotlin/com/tokilabs/toki_socket/TcpTest.kt` | 최종 검증 follow-up: blocking accept를 `Dispatchers.IO`로 이동 |
|
||||||
|
| `kotlin/src/main/kotlin/com/tokilabs/toki_socket/Communicator.kt` | REVIEW_API-5 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 최종 검증
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Kotlin 전체 단위 테스트
|
||||||
|
cd kotlin
|
||||||
|
./gradlew test
|
||||||
|
# 예상: 모든 테스트 PASS (HeartbeatTest 4개 포함)
|
||||||
|
|
||||||
|
# 2. JAR에 crosstest 미포함 확인
|
||||||
|
./gradlew jar
|
||||||
|
jar tf build/libs/toki-socket-kotlin-0.1.0.jar | grep crosstest
|
||||||
|
# 예상: 출력 없음
|
||||||
|
|
||||||
|
# 3. Go 단위 테스트 회귀 없음
|
||||||
|
cd ../go && go test ./...
|
||||||
|
# 예상: ok toki-labs.com/toki_socket/go/test
|
||||||
|
|
||||||
|
# 4. proto 동기화
|
||||||
|
cd .. && bash tools/check_proto_sync.sh
|
||||||
|
# 예상: Proto schemas are in sync.
|
||||||
|
```
|
||||||
|
|
@ -4,6 +4,7 @@ set -euo pipefail
|
||||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
dart_proto="$repo_root/dart/lib/src/packets/message_common.proto"
|
dart_proto="$repo_root/dart/lib/src/packets/message_common.proto"
|
||||||
go_proto="$repo_root/go/packets/message_common.proto"
|
go_proto="$repo_root/go/packets/message_common.proto"
|
||||||
|
kotlin_proto="$repo_root/kotlin/src/main/proto/message_common.proto"
|
||||||
|
|
||||||
if [[ ! -f "$dart_proto" ]]; then
|
if [[ ! -f "$dart_proto" ]]; then
|
||||||
echo "Missing canonical Dart proto: $dart_proto" >&2
|
echo "Missing canonical Dart proto: $dart_proto" >&2
|
||||||
|
|
@ -15,12 +16,20 @@ if [[ ! -f "$go_proto" ]]; then
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [[ -d "$repo_root/kotlin" && ! -f "$kotlin_proto" ]]; then
|
||||||
|
echo "Missing Kotlin proto copy: $kotlin_proto" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
tmp_dir="$(mktemp -d)"
|
tmp_dir="$(mktemp -d)"
|
||||||
trap 'rm -rf "$tmp_dir"' EXIT
|
trap 'rm -rf "$tmp_dir"' EXIT
|
||||||
|
|
||||||
normalize_proto() {
|
normalize_proto() {
|
||||||
awk '
|
awk '
|
||||||
/^[[:space:]]*option go_package[[:space:]]*=.*;[[:space:]]*$/ { next }
|
/^[[:space:]]*option go_package[[:space:]]*=.*;[[:space:]]*$/ { next }
|
||||||
|
/^[[:space:]]*option java_package[[:space:]]*=.*;[[:space:]]*$/ { next }
|
||||||
|
/^[[:space:]]*option java_outer_classname[[:space:]]*=.*;[[:space:]]*$/ { next }
|
||||||
|
/^[[:space:]]*option java_multiple_files[[:space:]]*=.*;[[:space:]]*$/ { next }
|
||||||
/^[[:space:]]*$/ { next }
|
/^[[:space:]]*$/ { next }
|
||||||
{ print }
|
{ print }
|
||||||
' "$1"
|
' "$1"
|
||||||
|
|
@ -28,6 +37,9 @@ normalize_proto() {
|
||||||
|
|
||||||
normalize_proto "$dart_proto" >"$tmp_dir/dart.proto"
|
normalize_proto "$dart_proto" >"$tmp_dir/dart.proto"
|
||||||
normalize_proto "$go_proto" >"$tmp_dir/go.proto"
|
normalize_proto "$go_proto" >"$tmp_dir/go.proto"
|
||||||
|
if [[ -f "$kotlin_proto" ]]; then
|
||||||
|
normalize_proto "$kotlin_proto" >"$tmp_dir/kotlin.proto"
|
||||||
|
fi
|
||||||
|
|
||||||
if ! cmp -s "$tmp_dir/dart.proto" "$tmp_dir/go.proto"; then
|
if ! cmp -s "$tmp_dir/dart.proto" "$tmp_dir/go.proto"; then
|
||||||
echo "Proto schema mismatch: go/packets/message_common.proto must match the Dart canonical proto except option go_package." >&2
|
echo "Proto schema mismatch: go/packets/message_common.proto must match the Dart canonical proto except option go_package." >&2
|
||||||
|
|
@ -35,4 +47,10 @@ if ! cmp -s "$tmp_dir/dart.proto" "$tmp_dir/go.proto"; then
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [[ -f "$tmp_dir/kotlin.proto" ]] && ! cmp -s "$tmp_dir/dart.proto" "$tmp_dir/kotlin.proto"; then
|
||||||
|
echo "Proto schema mismatch: kotlin/src/main/proto/message_common.proto must match the Dart canonical proto except Java options." >&2
|
||||||
|
diff -u "$tmp_dir/dart.proto" "$tmp_dir/kotlin.proto" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
echo "Proto schemas are in sync."
|
echo "Proto schemas are in sync."
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue