fix(server): drain live games on SIGTERM and honour a second signal

The wsapi server was built on the signal context, so SIGTERM cancelled
every room before StartDraining ran and the restart notice never went
out. Build it on a background context, release the signal context as
soon as it fires, and wait a bounded moment after Shutdown so the notice
reaches open sockets.

Also: Hard prefers the slower loss in lost positions; resigning out of
turn no longer settles a pending dead end on the spot; a self-closing
<ref> with a slash in its name no longer swallows definition text; the
store enforces builder_version; both listeners get an IdleTimeout; the
real-corpus ladder is reproducible from its seed; a -healthcheck flag
probes /healthz for the container health check.
This commit is contained in:
tiennm99 committed 2026-09-29 20:33:16 +07:00
1 parent d3eb13e36e
commit 7fb724d29e
14 files changed
+600 -88

No files matched your search

+17
View File
@@ -280,3 +280,20 @@ func TestChooseIsDeterministicUnderSeed(t *testing.T) {
t.Errorf("same seed produced %q then %q", a, b)
}
}
// When every move loses inside the search horizon, Hard must play the one that
// loses later: the opponent may never find the slower refutation, and the
// faster one hands it over immediately.
func TestHardPrefersTheSlowerLossWhenEveryLineLoses(t *testing.T) {
// "s p" loses at once: the only reply "p z" leaves the mover with nothing.
// "s q" loses three plies later: q r, r t, t u, then "u" starts nothing.
b := board("s", "s p", "p z", "s q", "q r", "r t", "t u")
got, err := mustStrategy(t, Hard).Choose(b)
if err != nil {
t.Fatalf("Choose: %v", err)
}
if got != "s q" {
t.Errorf("Hard chose %q, want the slower loss %q", got, "s q")
}
}
+2 -1
View File
@@ -38,9 +38,10 @@ func playRealGame(tb testing.TB, dict game.Dictionary, first, second Strategy, s
store := dict.(*dictionary.Store)
var e *game.Engine
rng := rand.New(rand.NewPCG(seed, 3))
// RandomOpeningWord can still return a word the engine refuses, so retry.
for attempt := 0; attempt < 20 && e == nil; attempt++ {
opening, err := store.RandomOpeningWord(5)
opening, err := store.RandomOpeningWordFrom(rng, 5)
if err != nil {
tb.Fatalf("RandomOpeningWord: %v", err)
}
+8 -4
View File
@@ -23,8 +23,10 @@ const (
// ordered tightest-first, so the pruned tail is the least interesting.
branchCap = 12
// A win is the negation of a child's loseScore, so only the losing
// terminal needs a constant.
// A win is the negation of a child's loss, so only the losing terminal
// needs a constant. A loss found with more depth still to search is
// scored lower than one found later, so a lost position prefers the
// slower loss and a won one the faster win.
loseScore = -1000.0
)
@@ -150,9 +152,11 @@ func (s *search) negamax(current string, depth int, alpha, beta float64) float64
}
}
// No reply: the player to move has lost.
// No reply: the player to move has lost. The remaining depth breaks the
// tie between losses, so when every line is lost the search still avoids
// the ones that end at once — a human may not find the slower refutation.
if len(candidates) == 0 {
return loseScore
return loseScore - float64(depth)
}
if depth <= 0 {
// Negamax evaluates from the perspective of the player to move, so
+29 -5
View File
@@ -35,9 +35,10 @@ import (
// ErrNotFound is returned when a syllable has no entry in the dictionary.
var ErrNotFound = errors.New("dictionary: syllable not found")
// requiredBuilderVersion is the builder whose meta contract this store reads;
// it is named in the refusal of an older database.
const requiredBuilderVersion = "5"
// RequiredBuilderVersion is the builder whose meta contract this store reads.
// The builder stamps it into every database it writes, and Open refuses any
// database that carries a different one.
const RequiredBuilderVersion = "5"
// wordInfo holds the two syllables the chain rule needs. Both ends are kept:
// canonicalization can move either one, so the engine must never re-derive
@@ -166,6 +167,19 @@ func (s *Store) loadMeta(db *sql.DB) (declaredWords, declaredMeanings int, err e
return 0, 0, fmt.Errorf("read dictionary metadata (is this a noitu.db?): %w", err)
}
var builder string
if err := db.QueryRow(`SELECT value FROM meta WHERE key = 'builder_version'`).Scan(&builder); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return 0, 0, fmt.Errorf("dictionary has no builder_version: it predates builder_version %s — run 'make fetch-dict && make dict' to rebuild it",
RequiredBuilderVersion)
}
return 0, 0, fmt.Errorf("read dictionary builder_version: %w", err)
}
if builder != RequiredBuilderVersion {
return 0, 0, fmt.Errorf("dictionary was built by builder_version %q, this server reads %s — run 'make fetch-dict && make dict' to rebuild it",
builder, RequiredBuilderVersion)
}
count := func(key string) (int, error) {
var raw string
if err := db.QueryRow(`SELECT value FROM meta WHERE key = ?`, key).Scan(&raw); err != nil {
@@ -173,7 +187,7 @@ func (s *Store) loadMeta(db *sql.DB) (declaredWords, declaredMeanings int, err e
// A database from before the key existed: the fix is a rebuild,
// so say so rather than naming a missing row.
return 0, fmt.Errorf("dictionary has no %s: it predates builder_version %s — run 'make fetch-dict && make dict' to rebuild it",
key, requiredBuilderVersion)
key, RequiredBuilderVersion)
}
return 0, fmt.Errorf("read dictionary %s: %w", key, err)
}
@@ -483,6 +497,16 @@ func (s *Store) OutDegree(syllable string) (int, error) {
// prefix and the pick costs a binary search rather than a scan and a 720 KB
// allocation per room.
func (s *Store) RandomOpeningWord(minOutDegree int) (string, error) {
return s.pickOpeningWord(minOutDegree, rand.IntN)
}
// RandomOpeningWordFrom is RandomOpeningWord drawing from rng, so a simulation
// can replay the same openings from the same seed.
func (s *Store) RandomOpeningWordFrom(rng *rand.Rand, minOutDegree int) (string, error) {
return s.pickOpeningWord(minOutDegree, rng.IntN)
}
func (s *Store) pickOpeningWord(minOutDegree int, intN func(int) int) (string, error) {
n := sort.Search(len(s.openers), func(i int) bool {
return s.openers[i].lastOutDegree < minOutDegree
})
@@ -490,5 +514,5 @@ func (s *Store) RandomOpeningWord(minOutDegree int) (string, error) {
return "", fmt.Errorf("no word has a last syllable with at least %d continuations", minOutDegree)
}
return s.openers[rand.IntN(n)].word, nil
return s.openers[intN(n)].word, nil
}
+52 -8
View File
@@ -3,6 +3,7 @@ package dictionary
import (
"database/sql"
"errors"
"math/rand/v2"
"os"
"path/filepath"
"slices"
@@ -41,7 +42,7 @@ func fixtureAt(tb testing.TB, dir string) string {
defer func() { _ = db.Close() }()
data := fixtureSchema + `
INSERT INTO meta VALUES ('source_license','CC BY-SA 4.0'),('word_count','7'),('meaning_count','3');
INSERT INTO meta VALUES ('builder_version','` + RequiredBuilderVersion + `'),('source_license','CC BY-SA 4.0'),('word_count','7'),('meaning_count','3');
INSERT INTO words VALUES
('pháp luật','pháp','luật',2),
('pháp lý','pháp','lý',2),
@@ -112,7 +113,7 @@ func TestOpenWrongSchema(t *testing.T) {
// every room creation.
func TestOpenEmptyDictionary(t *testing.T) {
path := writeDB(t, fixtureSchema+`
INSERT INTO meta VALUES ('source_license','CC BY-SA 4.0'),('word_count','99999'),('meaning_count','0');`)
INSERT INTO meta VALUES ('builder_version','`+RequiredBuilderVersion+`'),('source_license','CC BY-SA 4.0'),('word_count','99999'),('meaning_count','0');`)
_, err := Open(path)
if err == nil {
@@ -127,7 +128,7 @@ INSERT INTO meta VALUES ('source_license','CC BY-SA 4.0'),('word_count','99999')
// syllable has continuations that cannot be supplied.
func TestOpenInconsistentOutDegree(t *testing.T) {
path := writeDB(t, fixtureSchema+`
INSERT INTO meta VALUES ('source_license','CC BY-SA 4.0'),('word_count','1'),('meaning_count','0');
INSERT INTO meta VALUES ('builder_version','`+RequiredBuilderVersion+`'),('source_license','CC BY-SA 4.0'),('word_count','1'),('meaning_count','0');
INSERT INTO words VALUES ('pháp luật','pháp','luật',2);
INSERT INTO syllables VALUES ('pháp',7),('luật',0);`)
@@ -138,7 +139,7 @@ INSERT INTO syllables VALUES ('pháp',7),('luật',0);`)
func TestOpenOrphanAlias(t *testing.T) {
path := writeDB(t, fixtureSchema+`
INSERT INTO meta VALUES ('source_license','CC BY-SA 4.0'),('word_count','1'),('meaning_count','0');
INSERT INTO meta VALUES ('builder_version','`+RequiredBuilderVersion+`'),('source_license','CC BY-SA 4.0'),('word_count','1'),('meaning_count','0');
INSERT INTO words VALUES ('pháp luật','pháp','luật',2);
INSERT INTO syllables VALUES ('pháp',1),('luật',0);
INSERT INTO aliases VALUES ('phap luat','không tồn tại');`)
@@ -276,7 +277,7 @@ func nearMissFixture(tb testing.TB) *Store {
tb.Helper()
path := writeDB(tb, fixtureSchema+`
INSERT INTO meta VALUES ('source_license','CC BY-SA 4.0'),('word_count','4'),('meaning_count','0');
INSERT INTO meta VALUES ('builder_version','`+RequiredBuilderVersion+`'),('source_license','CC BY-SA 4.0'),('word_count','4'),('meaning_count','0');
INSERT INTO words VALUES
('bình yên','bình','yên',2),
('an nhàn','an','nhàn',2),
@@ -469,6 +470,31 @@ func TestRandomOpeningWord(t *testing.T) {
}
}
// The same seed must replay the same openings, and an impossible minimum must
// still be refused when the caller supplies the source.
func TestRandomOpeningWordFromIsReproducible(t *testing.T) {
s := fixture(t)
draw := func(seed uint64) []string {
rng := rand.New(rand.NewPCG(seed, 1))
out := make([]string, 30)
for i := range out {
word, err := s.RandomOpeningWordFrom(rng, 1)
if err != nil {
t.Fatal(err)
}
out[i] = word
}
return out
}
if a, b := draw(7), draw(7); !slices.Equal(a, b) {
t.Errorf("the same seed drew different openings: %v vs %v", a, b)
}
if _, err := s.RandomOpeningWordFrom(rand.New(rand.NewPCG(1, 1)), 99); err == nil {
t.Error("RandomOpeningWordFrom succeeded with an unreachable minimum, want error")
}
}
// The minimum must actually filter, not just be accepted.
func TestRandomOpeningWordRespectsMinimum(t *testing.T) {
s := fixture(t)
@@ -654,7 +680,7 @@ func TestMeaningsAreOrderedAndCopied(t *testing.T) {
// served silently as a dictionary without meanings.
func TestOpenRefusesMismatchedMeaningCount(t *testing.T) {
path := writeDB(t, fixtureSchema+`
INSERT INTO meta VALUES ('source_license','CC BY-SA 4.0'),('word_count','1'),('meaning_count','2');
INSERT INTO meta VALUES ('builder_version','`+RequiredBuilderVersion+`'),('source_license','CC BY-SA 4.0'),('word_count','1'),('meaning_count','2');
INSERT INTO words VALUES ('pháp luật','pháp','luật',2);
INSERT INTO syllables VALUES ('pháp',1),('luật',0);
INSERT INTO meanings VALUES ('pháp luật',0,'danh từ','Luật.');`)
@@ -667,7 +693,7 @@ INSERT INTO meanings VALUES ('pháp luật',0,'danh từ','Luật.');`)
// A database built before meanings existed opens cleanly and has every table
// but one row. The refusal must say what to do, not which row is missing.
func TestOpenRefusesOlderBuilderVersion(t *testing.T) {
func TestOpenRefusesADatabaseWithNoBuilderVersion(t *testing.T) {
path := writeDB(t, fixtureSchema+`
INSERT INTO meta VALUES ('source_license','CC BY-SA 4.0'),('word_count','1');
INSERT INTO words VALUES ('pháp luật','pháp','luật',2);
@@ -679,9 +705,27 @@ INSERT INTO syllables VALUES ('pháp',1),('luật',0);`)
}
}
// The version row, not the presence of other keys, is what identifies a
// compatible builder: a database from another version that happens to carry
// every count must still be refused, by name.
func TestOpenRefusesAMismatchedBuilderVersion(t *testing.T) {
path := writeDB(t, fixtureSchema+`
INSERT INTO meta VALUES ('builder_version','4'),('source_license','CC BY-SA 4.0'),('word_count','1'),('meaning_count','0');
INSERT INTO words VALUES ('pháp luật','pháp','luật',2);
INSERT INTO syllables VALUES ('pháp',1),('luật',0);`)
_, err := Open(path)
if err == nil {
t.Fatal("Open accepted a database from another builder version")
}
if msg := err.Error(); !strings.Contains(msg, `"4"`) || !strings.Contains(msg, "make dict") {
t.Errorf("error %q should name the found version and say to rebuild", msg)
}
}
func TestOpenRefusesOrphanMeaning(t *testing.T) {
path := writeDB(t, fixtureSchema+`
INSERT INTO meta VALUES ('source_license','CC BY-SA 4.0'),('word_count','1'),('meaning_count','1');
INSERT INTO meta VALUES ('builder_version','`+RequiredBuilderVersion+`'),('source_license','CC BY-SA 4.0'),('word_count','1'),('meaning_count','1');
INSERT INTO words VALUES ('pháp luật','pháp','luật',2);
INSERT INTO syllables VALUES ('pháp',1),('luật',0);
INSERT INTO meanings VALUES ('không tồn tại',0,'','Một nghĩa.');`)
+8 -2
View File
@@ -479,9 +479,15 @@ func (e *Engine) Resign(p PlayerID, now time.Time) bool {
if !e.eliminate(p, EndResigned) {
return false
}
e.settle()
// Only a resignation that moved the turn may settle a dead end: the new
// player to act has already seen the board, which is what settle relies
// on. One from behind leaves a pending dead end to the player who faces it,
// on their own clock.
if !e.over && e.Turn() != before {
e.deadline = now.Add(e.turnLimit)
e.settle()
if !e.over {
e.deadline = now.Add(e.turnLimit)
}
}
return true
}
+39
View File
@@ -155,6 +155,45 @@ func TestADeadEndSettlesInOneTurnNotThree(t *testing.T) {
}
}
// A seat leaving from behind must not knock out the player who is facing a dead
// end: that player still loses it on their own clock, as the README describes.
func TestResignOutOfTurnDoesNotSettleAPendingDeadEnd(t *testing.T) {
d := newDict("a b", "b c", "c d")
e, err := New(d, []PlayerID{alice, bob, carol}, "a b", 20*time.Second, t0)
if err != nil {
t.Fatalf("New: %v", err)
}
if _, r := e.Submit(alice, "b c", t0); r != ReasonNone {
t.Fatalf("Submit b c: %s", r)
}
if _, r := e.Submit(bob, "c d", t0); r != ReasonNone {
t.Fatalf("Submit c d: %s", r)
}
deadline := e.Deadline()
// Carol is on turn with nothing to play; alice leaves a second later.
if !e.Resign(alice, t0.Add(time.Second)) {
t.Fatal("Resign returned false")
}
if e.Over() {
t.Fatal("an out-of-turn resignation ended the game before the player at the dead end had their turn")
}
if !e.Alive(carol) || e.Turn() != carol {
t.Errorf("Turn = %q, alive(carol) = %v; carol should still be on turn", e.Turn(), e.Alive(carol))
}
if !e.Deadline().Equal(deadline) {
t.Error("the resignation changed the player's clock")
}
if !e.Timeout(t0.Add(21 * time.Second)) {
t.Fatal("Timeout did not fire")
}
if !e.Over() || e.Winner() != bob {
t.Errorf("Over = %v, Winner = %q; want bob to win once carol's clock ran out", e.Over(), e.Winner())
}
}
// Past two seats a player may want out while somebody else is thinking, and
// holding them to a turn they have already given up on is not a rule worth
// having.