test(core): make the stash spawn ordering test deterministic
The test runs the spawn in a synctest bubble, waits until it blocks sending the pending flag, and checks the launcher queue is still empty before it reads the flag. Swapping the publish and the send makes it fail.
- commit
- 3470598abc8b2845adc59d9dd4ed21d1121f860b
- author
- brice · · 2026-10-14 20:42 UTC
- parent
- 2fb23d6
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
1 file · +30 -32
M internal/core/stash_spawn_internal_test.go +30 -32
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "errors" | |
| 6 | 6 | "strings" | |
| 7 | 7 | "testing" | |
| 8 | + | "testing/synctest" | |
| 8 | 9 | "time" | |
| 9 | 10 | ||
| 10 | 11 | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |
| @@ -40,42 +41,39 @@ func claimToken(req ports.SpawnRequest) string { | |||
| 40 | 41 | } | |
| 41 | 42 | ||
| 42 | 43 | // Wayland reads tokens only while slots are pending, so the flag is sent | |
| 43 | - | // before the spawn can reach the launcher. The command channel is unbuffered | |
| 44 | - | // and read last: the spawn can only follow the flag, never come first. | |
| 44 | + | // before the spawn can reach the launcher. In a synctest bubble the worker | |
| 45 | + | // is waited on until it blocks sending the flag on the unread command | |
| 46 | + | // channel: the spawn channel must still be empty then. | |
| 45 | 47 | func TestSpawnStashPublishesPendingBeforeSpawn(t *testing.T) { | |
| 46 | - | now := claimBase | |
| 47 | - | commands := make(chan ports.ClientCommand) | |
| 48 | - | spawn := make(chan ports.SpawnRequest, 1) | |
| 49 | - | c := stashSpawnCore(t, commands, spawn, fixedClock(t, &now)) | |
| 50 | - | ctx, cancel := context.WithCancel(context.Background()) | |
| 51 | - | t.Cleanup(cancel) | |
| 52 | - | done := make(chan error, 1) | |
| 53 | - | go func() { done <- c.spawnStash(ctx, c.cur().mon.Current(), [][]string{{"firefox"}}, false) }() | |
| 54 | - | // Both channels are awaited at once: the first to communicate wins. | |
| 55 | - | timeout := time.After(5 * time.Second) | |
| 56 | - | select { | |
| 57 | - | case v := <-commands: | |
| 58 | - | if p, ok := v.(ports.SlotsPending); !ok || !p.Pending { | |
| 59 | - | t.Fatalf("command %v", v) | |
| 48 | + | synctest.Test(t, func(t *testing.T) { | |
| 49 | + | now := claimBase | |
| 50 | + | commands := make(chan ports.ClientCommand) | |
| 51 | + | spawn := make(chan ports.SpawnRequest, 1) | |
| 52 | + | c := stashSpawnCore(t, commands, spawn, fixedClock(t, &now)) | |
| 53 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 54 | + | t.Cleanup(cancel) | |
| 55 | + | done := make(chan error, 1) | |
| 56 | + | go func() { done <- c.spawnStash(ctx, c.cur().mon.Current(), [][]string{{"firefox"}}, false) }() | |
| 57 | + | synctest.Wait() | |
| 58 | + | if len(spawn) != 0 { | |
| 59 | + | t.Fatal("spawned before the pending flag was delivered") | |
| 60 | 60 | } | |
| 61 | - | case req := <-spawn: | |
| 62 | - | t.Fatalf("spawned before the pending flag was delivered: %v", req) | |
| 63 | - | case <-timeout: | |
| 64 | - | t.Fatal("no SlotsPending") | |
| 65 | - | } | |
| 66 | - | select { | |
| 67 | - | case err := <-done: | |
| 68 | - | if err != nil { | |
| 61 | + | select { | |
| 62 | + | case err := <-done: | |
| 63 | + | t.Fatalf("spawnStash returned before the flag was read: %v", err) | |
| 64 | + | default: | |
| 65 | + | } | |
| 66 | + | if p, ok := (<-commands).(ports.SlotsPending); !ok || !p.Pending { | |
| 67 | + | t.Fatal("first command is not SlotsPending{true}") | |
| 68 | + | } | |
| 69 | + | synctest.Wait() | |
| 70 | + | if err := <-done; err != nil { | |
| 69 | 71 | t.Fatal(err) | |
| 70 | 72 | } | |
| 71 | - | case <-timeout: | |
| 72 | - | t.Fatal("spawnStash did not return") | |
| 73 | - | } | |
| 74 | - | select { | |
| 75 | - | case <-spawn: | |
| 76 | - | default: | |
| 77 | - | t.Fatal("no spawn") | |
| 78 | - | } | |
| 73 | + | if len(spawn) != 1 { | |
| 74 | + | t.Fatal("no spawn after the flag") | |
| 75 | + | } | |
| 76 | + | }) | |
| 79 | 77 | } | |
| 80 | 78 | ||
| 81 | 79 | // A send the launcher queue suppresses leaves no claim behind, and the | |
| @@ -5,6 +5,7 @@ import ( | |||||
| 5 | "errors" | 5 | "errors" | ||
| 6 | "strings" | 6 | "strings" | ||
| 7 | "testing" | 7 | "testing" | ||
| 8 | + | "testing/synctest" | |||
| 8 | "time" | 9 | "time" | ||
| 9 | 10 | ||||
| 10 | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | 11 | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | ||
| @@ -40,42 +41,39 @@ func claimToken(req ports.SpawnRequest) string { | |||||
| 40 | } | 41 | } | ||
| 41 | 42 | ||||
| 42 | // Wayland reads tokens only while slots are pending, so the flag is sent | 43 | // Wayland reads tokens only while slots are pending, so the flag is sent | ||
| 43 | - | // before the spawn can reach the launcher. The command channel is unbuffered | 44 | + | // before the spawn can reach the launcher. In a synctest bubble the worker |
| 44 | - | // and read last: the spawn can only follow the flag, never come first. | 45 | + | // is waited on until it blocks sending the flag on the unread command |
| 46 | + | // channel: the spawn channel must still be empty then. | |||
| 45 | func TestSpawnStashPublishesPendingBeforeSpawn(t *testing.T) { | 47 | func TestSpawnStashPublishesPendingBeforeSpawn(t *testing.T) { | ||
| 46 | - | now := claimBase | 48 | + | synctest.Test(t, func(t *testing.T) { |
| 47 | - | commands := make(chan ports.ClientCommand) | 49 | + | now := claimBase |
| 48 | - | spawn := make(chan ports.SpawnRequest, 1) | 50 | + | commands := make(chan ports.ClientCommand) |
| 49 | - | c := stashSpawnCore(t, commands, spawn, fixedClock(t, &now)) | 51 | + | spawn := make(chan ports.SpawnRequest, 1) |
| 50 | - | ctx, cancel := context.WithCancel(context.Background()) | 52 | + | c := stashSpawnCore(t, commands, spawn, fixedClock(t, &now)) |
| 51 | - | t.Cleanup(cancel) | 53 | + | ctx, cancel := context.WithCancel(context.Background()) |
| 52 | - | done := make(chan error, 1) | 54 | + | t.Cleanup(cancel) |
| 53 | - | go func() { done <- c.spawnStash(ctx, c.cur().mon.Current(), [][]string{{"firefox"}}, false) }() | 55 | + | done := make(chan error, 1) |
| 54 | - | // Both channels are awaited at once: the first to communicate wins. | 56 | + | go func() { done <- c.spawnStash(ctx, c.cur().mon.Current(), [][]string{{"firefox"}}, false) }() |
| 55 | - | timeout := time.After(5 * time.Second) | 57 | + | synctest.Wait() |
| 56 | - | select { | 58 | + | if len(spawn) != 0 { |
| 57 | - | case v := <-commands: | 59 | + | t.Fatal("spawned before the pending flag was delivered") |
| 58 | - | if p, ok := v.(ports.SlotsPending); !ok || !p.Pending { | |||
| 59 | - | t.Fatalf("command %v", v) | |||
| 60 | } | 60 | } | ||
| 61 | - | case req := <-spawn: | 61 | + | select { |
| 62 | - | t.Fatalf("spawned before the pending flag was delivered: %v", req) | 62 | + | case err := <-done: |
| 63 | - | case <-timeout: | 63 | + | t.Fatalf("spawnStash returned before the flag was read: %v", err) |
| 64 | - | t.Fatal("no SlotsPending") | 64 | + | default: |
| 65 | - | } | 65 | + | } |
| 66 | - | select { | 66 | + | if p, ok := (<-commands).(ports.SlotsPending); !ok || !p.Pending { |
| 67 | - | case err := <-done: | 67 | + | t.Fatal("first command is not SlotsPending{true}") |
| 68 | - | if err != nil { | 68 | + | } |
| 69 | + | synctest.Wait() | |||
| 70 | + | if err := <-done; err != nil { | |||
| 69 | t.Fatal(err) | 71 | t.Fatal(err) | ||
| 70 | } | 72 | } | ||
| 71 | - | case <-timeout: | 73 | + | if len(spawn) != 1 { |
| 72 | - | t.Fatal("spawnStash did not return") | 74 | + | t.Fatal("no spawn after the flag") |
| 73 | - | } | 75 | + | } |
| 74 | - | select { | 76 | + | }) |
| 75 | - | case <-spawn: | |||
| 76 | - | default: | |||
| 77 | - | t.Fatal("no spawn") | |||
| 78 | - | } | |||
| 79 | } | 77 | } | ||
| 80 | 78 | ||||
| 81 | // A send the launcher queue suppresses leaves no claim behind, and the | 79 | // A send the launcher queue suppresses leaves no claim behind, and the | ||