Refactor MTProxy checker to support multiple DC probing with enhanced timeout handling. Update README and Docker documentation to include new -dc-ids flag for sequential DC checks, clarify exit codes, and improve error reporting for each DC. Adjust logic in checkOneLine and related functions to aggregate results from multiple DCs, ensuring robust performance and clearer output.
This commit is contained in:
@@ -20,9 +20,9 @@ go build -o mtproxy_checker.exe ./cmd/mtproxy_checker
|
||||
.\mtproxy_checker.exe --server HOST --port PORT --secret HEX
|
||||
```
|
||||
|
||||
Флаги: `-timeout` (по умолчанию 15s), `-dc-id` (по умолчанию 2), `-probe fast|deep` (по умолчанию **`fast`** — как Telethon TcpMTProxy после init; **`deep`** — `req_pq`/`resPQ` до DC).
|
||||
Флаги: `-timeout` (по умолчанию 15s), `-dc-id` (по умолчанию 2), `-dc-ids` (например `1,2,3,4,5` — проверка **каждого** DC по очереди; `-timeout` на **один** DC; общий лимит времени умножается на число DC; код `0`, если **хотя бы один** DC прошёл; список удавшихся DC печатается в stderr), `-probe fast|deep` (по умолчанию **`fast`** — как Telethon TcpMTProxy после init; **`deep`** — `req_pq`/`resPQ` до DC).
|
||||
|
||||
Код выхода: `0` — OK (**`fast`**: рукопожатие + init, прокси не рвёт TCP сразу; **`deep`**: плюс `resPQ` от DC), `1` — ошибка (в **`deep`** в т.ч. нет валидного `resPQ`), `2` — неверные аргументы, `3` — прокси закрыл соединение после проверки, `4` — таймаут.
|
||||
Код выхода: `0` — OK (**`fast`**: рукопожатие + init, прокси не рвёт TCP сразу; **`deep`**: плюс `resPQ` от DC); при **нескольких** DC — `0`, если любой из них OK. `1` — ошибка (в **`deep`** в т.ч. нет валидного `resPQ`), `2` — неверные аргументы, `3` — прокси закрыл соединение после проверки, `4` — таймаут.
|
||||
|
||||
**Docker:** [docs/docker.ru.md](docs/docker.ru.md) — один образ: **с аргументами** после образа — CLI; **без аргументов** — HTTP API (файл со списком `tg://`, интервал, опциональный whitelist IP).
|
||||
|
||||
|
||||
+74
-22
@@ -10,6 +10,8 @@ import (
|
||||
"time"
|
||||
|
||||
"mtproxy_checker/internal/checker"
|
||||
"mtproxy_checker/internal/checkresult"
|
||||
"mtproxy_checker/internal/dclist"
|
||||
"mtproxy_checker/internal/parseurl"
|
||||
"mtproxy_checker/internal/secret"
|
||||
)
|
||||
@@ -21,7 +23,8 @@ func main() {
|
||||
func run() int {
|
||||
timeout := flag.Duration("timeout", 15*time.Second, "overall TCP/handshake timeout")
|
||||
probe := flag.String("probe", "fast", "fast: handshake+init, Telethon-style post-init wait (no immediate close); deep: req_pq/resPQ via DC")
|
||||
dcID := flag.Int("dc-id", 2, "Telegram DC id (signed int16) embedded in MTProxy header")
|
||||
dcID := flag.Int("dc-id", 2, "Telegram DC id (signed int16) embedded in MTProxy header (ignored if -dc-ids is set)")
|
||||
dcIDsFlag := flag.String("dc-ids", "", "comma-separated DC ids to probe in order (e.g. 1,2,3,4,5); OK if any succeeds; timeout is per-DC")
|
||||
server := flag.String("server", "", "proxy hostname (if not using tg:// positional)")
|
||||
portFlag := flag.Int("port", 0, "proxy port (if not using tg:// positional)")
|
||||
secFlag := flag.String("secret", "", "hex secret (if not using tg:// positional)")
|
||||
@@ -60,30 +63,79 @@ func run() int {
|
||||
return 2
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
|
||||
defer cancel()
|
||||
dcIDs, err := dclist.ParseList(*dcIDsFlag)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "-dc-ids: %v\n", err)
|
||||
return 2
|
||||
}
|
||||
if dcIDs == nil {
|
||||
dcIDs = []int16{int16(*dcID)}
|
||||
}
|
||||
|
||||
opts := &checker.Options{Probe: checker.ParseProbe(*probe)}
|
||||
err = checker.Check(ctx, host, port, parsed, int16(*dcID), opts)
|
||||
if err != nil {
|
||||
if errors.Is(err, checker.ErrProxyClosed) {
|
||||
fmt.Fprintf(os.Stderr, "FAIL: %v\n", err)
|
||||
return 3
|
||||
}
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
fmt.Fprintf(os.Stderr, "FAIL: timeout\n")
|
||||
return 4
|
||||
}
|
||||
var ne net.Error
|
||||
if errors.As(err, &ne) && ne.Timeout() {
|
||||
fmt.Fprintf(os.Stderr, "FAIL: timeout\n")
|
||||
return 4
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "FAIL: %v\n", err)
|
||||
return 1
|
||||
total := *timeout
|
||||
if len(dcIDs) > 1 {
|
||||
total = *timeout * time.Duration(len(dcIDs))
|
||||
}
|
||||
fmt.Println("OK")
|
||||
return 0
|
||||
ctx, cancel := context.WithTimeout(context.Background(), total)
|
||||
defer cancel()
|
||||
|
||||
if len(dcIDs) == 1 {
|
||||
dcCtx, dcCancel := context.WithTimeout(ctx, *timeout)
|
||||
err = checker.Check(dcCtx, host, port, parsed, dcIDs[0], opts)
|
||||
dcCancel()
|
||||
if err != nil {
|
||||
return classifyCLIExit(err)
|
||||
}
|
||||
fmt.Println("OK")
|
||||
return 0
|
||||
}
|
||||
|
||||
anyOK := false
|
||||
var passedDCs []int16
|
||||
for _, dc := range dcIDs {
|
||||
dcCtx, dcCancel := context.WithTimeout(ctx, *timeout)
|
||||
err := checker.Check(dcCtx, host, port, parsed, dc, opts)
|
||||
dcCancel()
|
||||
code, msg := checkresult.Classify(err)
|
||||
ok := err == nil
|
||||
if ok {
|
||||
anyOK = true
|
||||
passedDCs = append(passedDCs, dc)
|
||||
} else {
|
||||
if msg != "" {
|
||||
fmt.Fprintf(os.Stderr, "FAIL dc=%d (%d): %s\n", dc, code, msg)
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "FAIL dc=%d (%d): %v\n", dc, code, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if anyOK {
|
||||
if len(dcIDs) > 1 {
|
||||
fmt.Fprintf(os.Stderr, "passed DCs: %v\n", passedDCs)
|
||||
}
|
||||
fmt.Println("OK")
|
||||
return 0
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
func classifyCLIExit(err error) int {
|
||||
if errors.Is(err, checker.ErrProxyClosed) {
|
||||
fmt.Fprintf(os.Stderr, "FAIL: %v\n", err)
|
||||
return 3
|
||||
}
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
fmt.Fprintf(os.Stderr, "FAIL: timeout\n")
|
||||
return 4
|
||||
}
|
||||
var ne net.Error
|
||||
if errors.As(err, &ne) && ne.Timeout() {
|
||||
fmt.Fprintf(os.Stderr, "FAIL: timeout\n")
|
||||
return 4
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "FAIL: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
func hasPrefix(s, p string) bool {
|
||||
|
||||
+105
-26
@@ -19,6 +19,7 @@ import (
|
||||
"mtproxy_checker/internal/allowlist"
|
||||
"mtproxy_checker/internal/checker"
|
||||
"mtproxy_checker/internal/checkresult"
|
||||
"mtproxy_checker/internal/dclist"
|
||||
"mtproxy_checker/internal/parseurl"
|
||||
"mtproxy_checker/internal/secret"
|
||||
)
|
||||
@@ -35,7 +36,7 @@ type config struct {
|
||||
checkInterval time.Duration
|
||||
httpAddr string
|
||||
checkTimeout time.Duration
|
||||
dcID int16
|
||||
dcIDs []int16
|
||||
probe checker.ProbeMode
|
||||
allowedPrefixes []netip.Prefix
|
||||
}
|
||||
@@ -66,14 +67,22 @@ func loadConfig() (*config, error) {
|
||||
if err != nil || checkTimeout <= 0 {
|
||||
return nil, fmt.Errorf("MTPROXY_CHECK_TIMEOUT: invalid duration %q", timeoutStr)
|
||||
}
|
||||
dcStr := strings.TrimSpace(os.Getenv("MTPROXY_DC_ID"))
|
||||
if dcStr == "" {
|
||||
dcStr = "2"
|
||||
dcIDsRaw := strings.TrimSpace(os.Getenv("MTPROXY_DC_IDS"))
|
||||
dcIDs, err := dclist.ParseList(dcIDsRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("MTPROXY_DC_IDS: %w", err)
|
||||
}
|
||||
var dcParsed int64
|
||||
_, err = fmt.Sscanf(dcStr, "%d", &dcParsed)
|
||||
if err != nil || dcParsed < -32768 || dcParsed > 32767 {
|
||||
return nil, fmt.Errorf("MTPROXY_DC_ID: invalid int16 %q", dcStr)
|
||||
if dcIDs == nil {
|
||||
dcStr := strings.TrimSpace(os.Getenv("MTPROXY_DC_ID"))
|
||||
if dcStr == "" {
|
||||
dcStr = "2"
|
||||
}
|
||||
var dcParsed int64
|
||||
_, err = fmt.Sscanf(dcStr, "%d", &dcParsed)
|
||||
if err != nil || dcParsed < -32768 || dcParsed > 32767 {
|
||||
return nil, fmt.Errorf("MTPROXY_DC_ID: invalid int16 %q", dcStr)
|
||||
}
|
||||
dcIDs = []int16{int16(dcParsed)}
|
||||
}
|
||||
allowedRaw := os.Getenv("MTPROXY_ALLOWED_IPS")
|
||||
prefixes, err := allowlist.ParseCommaList(allowedRaw)
|
||||
@@ -86,20 +95,28 @@ func loadConfig() (*config, error) {
|
||||
checkInterval: interval,
|
||||
httpAddr: httpAddr,
|
||||
checkTimeout: checkTimeout,
|
||||
dcID: int16(dcParsed),
|
||||
dcIDs: dcIDs,
|
||||
probe: probe,
|
||||
allowedPrefixes: prefixes,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type dcProbeResult struct {
|
||||
DC int16 `json:"dc"`
|
||||
OK bool `json:"ok"`
|
||||
ExitCode int `json:"exit_code"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type proxyEntry struct {
|
||||
RawLine string `json:"raw_line"`
|
||||
URL string `json:"url,omitempty"`
|
||||
OK bool `json:"ok"`
|
||||
ExitCode int `json:"exit_code"`
|
||||
Error string `json:"error,omitempty"`
|
||||
ParseError string `json:"parse_error,omitempty"`
|
||||
CheckedAt string `json:"checked_at,omitempty"`
|
||||
RawLine string `json:"raw_line"`
|
||||
URL string `json:"url,omitempty"`
|
||||
OK bool `json:"ok"`
|
||||
ExitCode int `json:"exit_code"`
|
||||
Error string `json:"error,omitempty"`
|
||||
ParseError string `json:"parse_error,omitempty"`
|
||||
CheckedAt string `json:"checked_at,omitempty"`
|
||||
DCs []dcProbeResult `json:"dcs,omitempty"`
|
||||
}
|
||||
|
||||
type snapshot struct {
|
||||
@@ -149,9 +166,33 @@ func readProxyLines(path string) ([]string, error) {
|
||||
return lines, nil
|
||||
}
|
||||
|
||||
func checkOneLine(ctx context.Context, line string, dcID int16, probe checker.ProbeMode) proxyEntry {
|
||||
func aggregateExitFromDCs(dcs []dcProbeResult) int {
|
||||
has3, has4 := false, false
|
||||
for _, d := range dcs {
|
||||
if d.ExitCode == 3 {
|
||||
has3 = true
|
||||
}
|
||||
if d.ExitCode == 4 {
|
||||
has4 = true
|
||||
}
|
||||
}
|
||||
if has3 {
|
||||
return 3
|
||||
}
|
||||
if has4 {
|
||||
return 4
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
func checkOneLine(ctx context.Context, line string, dcIDs []int16, perDCTimeout time.Duration, probe checker.ProbeMode) proxyEntry {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
ent := proxyEntry{RawLine: line, CheckedAt: now}
|
||||
if len(dcIDs) == 0 {
|
||||
ent.ExitCode = 2
|
||||
ent.Error = "no DC ids configured"
|
||||
return ent
|
||||
}
|
||||
t, err := parseurl.ParseTGProxy(line)
|
||||
if err != nil {
|
||||
ent.ExitCode = 2
|
||||
@@ -167,12 +208,46 @@ func checkOneLine(ctx context.Context, line string, dcID int16, probe checker.Pr
|
||||
ent.Error = ent.ParseError
|
||||
return ent
|
||||
}
|
||||
err = checker.Check(ctx, t.Host, t.Port, parsed, dcID, &checker.Options{Probe: probe})
|
||||
code, msg := checkresult.Classify(err)
|
||||
ent.ExitCode = code
|
||||
ent.OK = err == nil
|
||||
if msg != "" {
|
||||
ent.Error = msg
|
||||
if len(dcIDs) == 1 {
|
||||
dcCtx, cancel := context.WithTimeout(ctx, perDCTimeout)
|
||||
defer cancel()
|
||||
err = checker.Check(dcCtx, t.Host, t.Port, parsed, dcIDs[0], &checker.Options{Probe: probe})
|
||||
code, msg := checkresult.Classify(err)
|
||||
ent.ExitCode = code
|
||||
ent.OK = err == nil
|
||||
if msg != "" {
|
||||
ent.Error = msg
|
||||
}
|
||||
return ent
|
||||
}
|
||||
var dcs []dcProbeResult
|
||||
anyOK := false
|
||||
for _, dc := range dcIDs {
|
||||
dcCtx, cancel := context.WithTimeout(ctx, perDCTimeout)
|
||||
err := checker.Check(dcCtx, t.Host, t.Port, parsed, dc, &checker.Options{Probe: probe})
|
||||
cancel()
|
||||
code, msg := checkresult.Classify(err)
|
||||
ok := err == nil
|
||||
if ok {
|
||||
anyOK = true
|
||||
}
|
||||
dcs = append(dcs, dcProbeResult{DC: dc, OK: ok, ExitCode: code, Error: msg})
|
||||
}
|
||||
ent.DCs = dcs
|
||||
ent.OK = anyOK
|
||||
if ent.OK {
|
||||
ent.ExitCode = 0
|
||||
} else {
|
||||
ent.ExitCode = aggregateExitFromDCs(dcs)
|
||||
var parts []string
|
||||
for _, d := range dcs {
|
||||
if d.Error != "" {
|
||||
parts = append(parts, fmt.Sprintf("dc%d: %s", d.DC, d.Error))
|
||||
} else {
|
||||
parts = append(parts, fmt.Sprintf("dc%d: fail", d.DC))
|
||||
}
|
||||
}
|
||||
ent.Error = strings.Join(parts, "; ")
|
||||
}
|
||||
return ent
|
||||
}
|
||||
@@ -193,8 +268,12 @@ func runCycle(cfg *config, st *store) {
|
||||
}
|
||||
entries := make([]proxyEntry, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), cfg.checkTimeout)
|
||||
ent := checkOneLine(ctx, line, cfg.dcID, cfg.probe)
|
||||
total := cfg.checkTimeout
|
||||
if len(cfg.dcIDs) > 1 {
|
||||
total = cfg.checkTimeout * time.Duration(len(cfg.dcIDs))
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), total)
|
||||
ent := checkOneLine(ctx, line, cfg.dcIDs, cfg.checkTimeout, cfg.probe)
|
||||
cancel()
|
||||
entries = append(entries, ent)
|
||||
}
|
||||
@@ -274,7 +353,7 @@ func run() error {
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
log.Printf("listening on %s, list=%s interval=%s", cfg.httpAddr, cfg.listFile, cfg.checkInterval)
|
||||
log.Printf("listening on %s, list=%s interval=%s dcs=%v", cfg.httpAddr, cfg.listFile, cfg.checkInterval, cfg.dcIDs)
|
||||
errCh <- srv.ListenAndServe()
|
||||
}()
|
||||
|
||||
|
||||
+4
-3
@@ -49,9 +49,10 @@ docker run -d --name mtproxy-api --restart unless-stopped -p 8080:8080 `
|
||||
| `MTPROXY_LIST_FILE` | `/data/proxies.txt` | Путь к файлу: одна `tg://` ссылка на строку; пустые строки и строки с `#` в начале пропускаются |
|
||||
| `MTPROXY_CHECK_INTERVAL` | `5m` | Интервал между циклами (`time.ParseDuration`, например `5m`, `1h`) |
|
||||
| `MTPROXY_HTTP_ADDR` | `:8080` | Адрес прослушивания HTTP |
|
||||
| `MTPROXY_CHECK_TIMEOUT` | `45s` (в демоне по умолчанию; CLI по-прежнему `15s` если не задано) | Таймаут **всей** одной проверки; для `MTPROXY_PROBE=deep` нужен запас (TLS + drain + ответ DC) |
|
||||
| `MTPROXY_CHECK_TIMEOUT` | `45s` (в демоне по умолчанию; CLI по-прежнему `15s` если не задано) | Таймаут **одной** попытки к выбранному DC; для `MTPROXY_PROBE=deep` нужен запас (TLS + drain + ответ DC). Если задан `MTPROXY_DC_IDS` с несколькими DC, общий бюджет цикла на строку ≈ `таймаут × число_DC` |
|
||||
| `MTPROXY_PROBE` | *(пусто)* → **`fast`** | `fast` — рукопожатие + init + короткое ожидание как в Telethon TcpMTProxy (#1134): OK, если прокси **не** рвёт TCP сразу после init (входящие байты не обязательны). `deep` — `req_pq`/`resPQ` через DC (строже, дольше) |
|
||||
| `MTPROXY_DC_ID` | `2` | DC id (аналог `-dc-id` CLI) |
|
||||
| `MTPROXY_DC_ID` | `2` | Один DC id (аналог `-dc-id` CLI), **игнорируется**, если задан непустой `MTPROXY_DC_IDS` |
|
||||
| `MTPROXY_DC_IDS` | *(пусто)* | Список DC через запятую, например `1,2,3,4,5`: для каждой строки прокси выполняется отдельная проверка на каждый DC **по очереди**. В JSON у записи появляется массив `dcs[]` с результатом по каждому DC. Поле `ok` у строки — **true, если хотя бы один DC прошёл** (как при «есть живой путь к Telegram» при переборе DC) |
|
||||
| `MTPROXY_ALLOWED_IPS` | *(не задана)* | Если задана непустая строка — доступ к **всем** маршрутам только с перечисленных IP/CIDR; остальные получают **403** и JSON `{"error":"forbidden"}`. Формат: через запятую, пробелы допускаются: `192.168.1.10`, `10.0.0.0/8`, IPv6 и CIDR вида `2001:db8::/32`. Учитывается только **`RemoteAddr`** TCP-соединения; заголовок `X-Forwarded-For` **не** используется |
|
||||
|
||||
### HTTP
|
||||
@@ -61,7 +62,7 @@ docker run -d --name mtproxy-api --restart unless-stopped -p 8080:8080 `
|
||||
| GET | `/health` | `200`, `{"status":"ok"}` |
|
||||
| GET | `/api/v1/proxies` | `200`, JSON с полями `cycle_finished_at`, `next_check_after`, массив `proxies` |
|
||||
|
||||
Элемент `proxies[]`: `raw_line`, при успешном разборе — `url`, `ok`, `exit_code` (`0` OK — при `MTPROXY_PROBE=fast`: рукопожатие + init и прокси не закрыл TCP сразу (как Telethon #1134); при **`deep`**: дополнительно получен `resPQ` от DC через туннель); `1` ошибка проверки, в т.ч. нет `resPQ` за время ожидания в `deep`; `2` ошибка разбора URL/секрета; `3` прокси закрыл соединение; `4` таймаут всего запроса), `error`, при необходимости `parse_error`, `checked_at`.
|
||||
Элемент `proxies[]`: `raw_line`, при успешном разборе — `url`, `ok`, `exit_code` (`0` OK — при `MTPROXY_PROBE=fast`: рукопожатие + init и прокси не закрыл TCP сразу (как Telethon #1134); при **`deep`**: дополнительно получен `resPQ` от DC через туннель); `1` ошибка проверки, в т.ч. нет `resPQ` за время ожидания в `deep`; `2` ошибка разбора URL/секрета; `3` прокси закрыл соединение; `4` таймаут всего запроса), `error`, при необходимости `parse_error`, `checked_at`. Если задан `MTPROXY_DC_IDS` с **несколькими** DC, добавляется массив `dcs` (`dc`, `ok`, `exit_code`, `error` по каждому); агрегатный `ok` — true, если **хотя бы один** DC успешен.
|
||||
|
||||
### Whitelist IP и Docker
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// Package dclist parses comma-separated Telegram DC ids (int16).
|
||||
package dclist
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ParseList parses "1,2,5" or "2". Empty string returns (nil, nil) — caller uses single-DC default.
|
||||
func ParseList(s string) ([]int16, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return nil, nil
|
||||
}
|
||||
parts := strings.Split(s, ",")
|
||||
out := make([]int16, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
v, err := strconv.ParseInt(p, 10, 16)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dc id %q: %w", p, err)
|
||||
}
|
||||
if v < -32768 || v > 32767 {
|
||||
return nil, fmt.Errorf("dc id %d out of int16 range", v)
|
||||
}
|
||||
out = append(out, int16(v))
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, fmt.Errorf("empty dc list after parsing %q", s)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package dclist
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseList(t *testing.T) {
|
||||
got, err := ParseList("")
|
||||
if err != nil || got != nil {
|
||||
t.Fatalf("empty: got %v err %v", got, err)
|
||||
}
|
||||
got, err = ParseList(" 1 , 2 , 5 ")
|
||||
if err != nil || !reflect.DeepEqual(got, []int16{1, 2, 5}) {
|
||||
t.Fatalf("1,2,5: got %v err %v", got, err)
|
||||
}
|
||||
got, err = ParseList("-1")
|
||||
if err != nil || !reflect.DeepEqual(got, []int16{-1}) {
|
||||
t.Fatalf("-1: got %v err %v", got, err)
|
||||
}
|
||||
if _, err := ParseList("foo"); err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user