refactor(core): give spawn placement targets an explicit kind
- commit
- cb04ab13df080b7e71ec7f58405fcbe359bd9d35
- author
- brice · · 2026-10-14 20:18 UTC
- parent
- 24a63d3
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +44 -29
M internal/core/spawn_placement.go +36 -21
| @@ -15,11 +15,23 @@ type spawnPlacement struct { | |||
| 15 | 15 | slots map[slotKey]string | |
| 16 | 16 | } | |
| 17 | 17 | ||
| 18 | - | // spawnTarget identifies the destination of one pending spawn. | |
| 18 | + | // spawnKind tells what a pending spawn is destined for. | |
| 19 | + | type spawnKind uint8 | |
| 20 | + | ||
| 21 | + | const ( | |
| 22 | + | // spawnSlot fills a slot; the zero value. | |
| 23 | + | spawnSlot spawnKind = iota | |
| 24 | + | // spawnTerminal is an automatic terminal for an empty workspace. | |
| 25 | + | spawnTerminal | |
| 26 | + | ) | |
| 27 | + | ||
| 28 | + | // spawnTarget identifies the destination of one pending spawn. slot is set | |
| 29 | + | // for spawnSlot; ws and at for the workspace-bound kinds. | |
| 19 | 30 | type spawnTarget struct { | |
| 20 | - | slot slotKey | |
| 21 | - | terminal *Workspace | |
| 22 | - | at time.Time | |
| 31 | + | kind spawnKind | |
| 32 | + | slot slotKey | |
| 33 | + | ws *Workspace | |
| 34 | + | at time.Time | |
| 23 | 35 | } | |
| 24 | 36 | ||
| 25 | 37 | func newSpawnPlacement() spawnPlacement { | |
| @@ -30,7 +42,7 @@ func newSpawnPlacement() spawnPlacement { | |||
| 30 | 42 | func (p *spawnPlacement) request(argv []string, target spawnTarget) ports.SpawnRequest { | |
| 31 | 43 | token := rand.Text() | |
| 32 | 44 | p.pending[token] = target | |
| 33 | - | if target.terminal == nil { | |
| 45 | + | if target.kind == spawnSlot { | |
| 34 | 46 | p.slots[target.slot] = token | |
| 35 | 47 | } | |
| 36 | 48 | return ports.SpawnRequest{Argv: slices.Clone(argv), Env: []string{ports.SlotEnv + "=" + token}} | |
| @@ -50,20 +62,20 @@ func (p *spawnPlacement) slotPending(key slotKey) bool { return p.slots[key] != | |||
| 50 | 62 | // anyPending reports whether any spawn still awaits a window. | |
| 51 | 63 | func (p *spawnPlacement) anyPending() bool { return len(p.pending) != 0 } | |
| 52 | 64 | ||
| 53 | - | // terminalPending reports whether a workspace awaits an automatic terminal. | |
| 54 | - | func (p *spawnPlacement) terminalPending(w *Workspace) bool { | |
| 65 | + | // workspacePending reports whether a workspace awaits a spawn of the given kind. | |
| 66 | + | func (p *spawnPlacement) workspacePending(w *Workspace, kind spawnKind) bool { | |
| 55 | 67 | for _, target := range p.pending { | |
| 56 | - | if target.terminal == w { | |
| 68 | + | if target.kind == kind && target.ws == w { | |
| 57 | 69 | return true | |
| 58 | 70 | } | |
| 59 | 71 | } | |
| 60 | 72 | return false | |
| 61 | 73 | } | |
| 62 | 74 | ||
| 63 | - | // expireTerminals drops terminal claims older than the retry interval. | |
| 64 | - | func (p *spawnPlacement) expireTerminals(now time.Time) { | |
| 75 | + | // expireWorkspaceClaims drops workspace-bound claims older than the retry interval. | |
| 76 | + | func (p *spawnPlacement) expireWorkspaceClaims(now time.Time) { | |
| 65 | 77 | for token, target := range p.pending { | |
| 66 | - | if target.terminal != nil && now.Sub(target.at) > termRetry { | |
| 78 | + | if target.kind != spawnSlot && now.Sub(target.at) > termRetry { | |
| 67 | 79 | delete(p.pending, token) | |
| 68 | 80 | } | |
| 69 | 81 | } | |
| @@ -95,22 +107,25 @@ func (p *spawnPlacement) place(c *Core, v ports.WindowMapped) { | |||
| 95 | 107 | } | |
| 96 | 108 | if v.Slot != "" { | |
| 97 | 109 | if target, ok := p.pending[v.Slot]; ok { | |
| 98 | - | if target.terminal != nil { | |
| 110 | + | switch target.kind { | |
| 111 | + | case spawnTerminal: | |
| 99 | 112 | delete(p.pending, v.Slot) | |
| 100 | 113 | for _, s := range c.screens { | |
| 101 | - | if s.mon.has(target.terminal) { | |
| 102 | - | target.terminal.AddWindow(v.ID) | |
| 114 | + | if s.mon.has(target.ws) { | |
| 115 | + | target.ws.AddWindow(v.ID) | |
| 103 | 116 | s.mon.normalize() | |
| 104 | 117 | return | |
| 105 | 118 | } | |
| 106 | 119 | } | |
| 107 | - | } else if st := c.slots[target.slot]; st != nil && st.window == 0 { | |
| 108 | - | if sc, w := c.byName(target.slot.workspace); w != nil { | |
| 109 | - | p.dropSlot(target.slot) | |
| 110 | - | st.window = v.ID | |
| 111 | - | w.AddSlotWindow(v.ID, target.slot.index, st.cells) | |
| 112 | - | sc.mon.normalize() | |
| 113 | - | return | |
| 120 | + | case spawnSlot: | |
| 121 | + | if st := c.slots[target.slot]; st != nil && st.window == 0 { | |
| 122 | + | if sc, w := c.byName(target.slot.workspace); w != nil { | |
| 123 | + | p.dropSlot(target.slot) | |
| 124 | + | st.window = v.ID | |
| 125 | + | w.AddSlotWindow(v.ID, target.slot.index, st.cells) | |
| 126 | + | sc.mon.normalize() | |
| 127 | + | return | |
| 128 | + | } | |
| 114 | 129 | } | |
| 115 | 130 | } | |
| 116 | 131 | } | |
| @@ -15,11 +15,23 @@ type spawnPlacement struct { | |||||
| 15 | slots map[slotKey]string | 15 | slots map[slotKey]string | ||
| 16 | } | 16 | } | ||
| 17 | 17 | ||||
| 18 | - | // spawnTarget identifies the destination of one pending spawn. | 18 | + | // spawnKind tells what a pending spawn is destined for. |
| 19 | + | type spawnKind uint8 | |||
| 20 | + | ||||
| 21 | + | const ( | |||
| 22 | + | // spawnSlot fills a slot; the zero value. | |||
| 23 | + | spawnSlot spawnKind = iota | |||
| 24 | + | // spawnTerminal is an automatic terminal for an empty workspace. | |||
| 25 | + | spawnTerminal | |||
| 26 | + | ) | |||
| 27 | + | ||||
| 28 | + | // spawnTarget identifies the destination of one pending spawn. slot is set | |||
| 29 | + | // for spawnSlot; ws and at for the workspace-bound kinds. | |||
| 19 | type spawnTarget struct { | 30 | type spawnTarget struct { | ||
| 20 | - | slot slotKey | 31 | + | kind spawnKind |
| 21 | - | terminal *Workspace | 32 | + | slot slotKey |
| 22 | - | at time.Time | 33 | + | ws *Workspace |
| 34 | + | at time.Time | |||
| 23 | } | 35 | } | ||
| 24 | 36 | ||||
| 25 | func newSpawnPlacement() spawnPlacement { | 37 | func newSpawnPlacement() spawnPlacement { | ||
| @@ -30,7 +42,7 @@ func newSpawnPlacement() spawnPlacement { | |||||
| 30 | func (p *spawnPlacement) request(argv []string, target spawnTarget) ports.SpawnRequest { | 42 | func (p *spawnPlacement) request(argv []string, target spawnTarget) ports.SpawnRequest { | ||
| 31 | token := rand.Text() | 43 | token := rand.Text() | ||
| 32 | p.pending[token] = target | 44 | p.pending[token] = target | ||
| 33 | - | if target.terminal == nil { | 45 | + | if target.kind == spawnSlot { |
| 34 | p.slots[target.slot] = token | 46 | p.slots[target.slot] = token | ||
| 35 | } | 47 | } | ||
| 36 | return ports.SpawnRequest{Argv: slices.Clone(argv), Env: []string{ports.SlotEnv + "=" + token}} | 48 | return ports.SpawnRequest{Argv: slices.Clone(argv), Env: []string{ports.SlotEnv + "=" + token}} | ||
| @@ -50,20 +62,20 @@ func (p *spawnPlacement) slotPending(key slotKey) bool { return p.slots[key] != | |||||
| 50 | // anyPending reports whether any spawn still awaits a window. | 62 | // anyPending reports whether any spawn still awaits a window. | ||
| 51 | func (p *spawnPlacement) anyPending() bool { return len(p.pending) != 0 } | 63 | func (p *spawnPlacement) anyPending() bool { return len(p.pending) != 0 } | ||
| 52 | 64 | ||||
| 53 | - | // terminalPending reports whether a workspace awaits an automatic terminal. | 65 | + | // workspacePending reports whether a workspace awaits a spawn of the given kind. |
| 54 | - | func (p *spawnPlacement) terminalPending(w *Workspace) bool { | 66 | + | func (p *spawnPlacement) workspacePending(w *Workspace, kind spawnKind) bool { |
| 55 | for _, target := range p.pending { | 67 | for _, target := range p.pending { | ||
| 56 | - | if target.terminal == w { | 68 | + | if target.kind == kind && target.ws == w { |
| 57 | return true | 69 | return true | ||
| 58 | } | 70 | } | ||
| 59 | } | 71 | } | ||
| 60 | return false | 72 | return false | ||
| 61 | } | 73 | } | ||
| 62 | 74 | ||||
| 63 | - | // expireTerminals drops terminal claims older than the retry interval. | 75 | + | // expireWorkspaceClaims drops workspace-bound claims older than the retry interval. |
| 64 | - | func (p *spawnPlacement) expireTerminals(now time.Time) { | 76 | + | func (p *spawnPlacement) expireWorkspaceClaims(now time.Time) { |
| 65 | for token, target := range p.pending { | 77 | for token, target := range p.pending { | ||
| 66 | - | if target.terminal != nil && now.Sub(target.at) > termRetry { | 78 | + | if target.kind != spawnSlot && now.Sub(target.at) > termRetry { |
| 67 | delete(p.pending, token) | 79 | delete(p.pending, token) | ||
| 68 | } | 80 | } | ||
| 69 | } | 81 | } | ||
| @@ -95,22 +107,25 @@ func (p *spawnPlacement) place(c *Core, v ports.WindowMapped) { | |||||
| 95 | } | 107 | } | ||
| 96 | if v.Slot != "" { | 108 | if v.Slot != "" { | ||
| 97 | if target, ok := p.pending[v.Slot]; ok { | 109 | if target, ok := p.pending[v.Slot]; ok { | ||
| 98 | - | if target.terminal != nil { | 110 | + | switch target.kind { |
| 111 | + | case spawnTerminal: | |||
| 99 | delete(p.pending, v.Slot) | 112 | delete(p.pending, v.Slot) | ||
| 100 | for _, s := range c.screens { | 113 | for _, s := range c.screens { | ||
| 101 | - | if s.mon.has(target.terminal) { | 114 | + | if s.mon.has(target.ws) { |
| 102 | - | target.terminal.AddWindow(v.ID) | 115 | + | target.ws.AddWindow(v.ID) |
| 103 | s.mon.normalize() | 116 | s.mon.normalize() | ||
| 104 | return | 117 | return | ||
| 105 | } | 118 | } | ||
| 106 | } | 119 | } | ||
| 107 | - | } else if st := c.slots[target.slot]; st != nil && st.window == 0 { | 120 | + | case spawnSlot: |
| 108 | - | if sc, w := c.byName(target.slot.workspace); w != nil { | 121 | + | if st := c.slots[target.slot]; st != nil && st.window == 0 { |
| 109 | - | p.dropSlot(target.slot) | 122 | + | if sc, w := c.byName(target.slot.workspace); w != nil { |
| 110 | - | st.window = v.ID | 123 | + | p.dropSlot(target.slot) |
| 111 | - | w.AddSlotWindow(v.ID, target.slot.index, st.cells) | 124 | + | st.window = v.ID |
| 112 | - | sc.mon.normalize() | 125 | + | w.AddSlotWindow(v.ID, target.slot.index, st.cells) |
| 113 | - | return | 126 | + | sc.mon.normalize() |
| 127 | + | return | |||
| 128 | + | } | |||
| 114 | } | 129 | } | ||
| 115 | } | 130 | } | ||
| 116 | } | 131 | } | ||
M internal/core/spawn_placement_test.go +3 -3
| @@ -60,13 +60,13 @@ func TestExpiredTerminalTokenMapsNormally(t *testing.T) { | |||
| 60 | 60 | t.Fatal("focus did not move to another workspace") | |
| 61 | 61 | } | |
| 62 | 62 | t0 := time.Unix(123, 0) | |
| 63 | - | req := c.placement.request([]string{"foot"}, spawnTarget{terminal: target, at: t0}) | |
| 63 | + | req := c.placement.request([]string{"foot"}, spawnTarget{kind: spawnTerminal, ws: target, at: t0}) | |
| 64 | 64 | token := strings.TrimPrefix(req.Env[0], ports.SlotEnv+"=") | |
| 65 | - | c.placement.expireTerminals(t0.Add(termRetry)) | |
| 65 | + | c.placement.expireWorkspaceClaims(t0.Add(termRetry)) | |
| 66 | 66 | if _, ok := c.placement.pending[token]; !ok { | |
| 67 | 67 | t.Fatal("claim expired at retry boundary") | |
| 68 | 68 | } | |
| 69 | - | c.placement.expireTerminals(t0.Add(termRetry + time.Nanosecond)) | |
| 69 | + | c.placement.expireWorkspaceClaims(t0.Add(termRetry + time.Nanosecond)) | |
| 70 | 70 | if _, ok := c.placement.pending[token]; ok { | |
| 71 | 71 | t.Fatal("claim survived past retry boundary") | |
| 72 | 72 | } | |
| @@ -60,13 +60,13 @@ func TestExpiredTerminalTokenMapsNormally(t *testing.T) { | |||||
| 60 | t.Fatal("focus did not move to another workspace") | 60 | t.Fatal("focus did not move to another workspace") | ||
| 61 | } | 61 | } | ||
| 62 | t0 := time.Unix(123, 0) | 62 | t0 := time.Unix(123, 0) | ||
| 63 | - | req := c.placement.request([]string{"foot"}, spawnTarget{terminal: target, at: t0}) | 63 | + | req := c.placement.request([]string{"foot"}, spawnTarget{kind: spawnTerminal, ws: target, at: t0}) |
| 64 | token := strings.TrimPrefix(req.Env[0], ports.SlotEnv+"=") | 64 | token := strings.TrimPrefix(req.Env[0], ports.SlotEnv+"=") | ||
| 65 | - | c.placement.expireTerminals(t0.Add(termRetry)) | 65 | + | c.placement.expireWorkspaceClaims(t0.Add(termRetry)) |
| 66 | if _, ok := c.placement.pending[token]; !ok { | 66 | if _, ok := c.placement.pending[token]; !ok { | ||
| 67 | t.Fatal("claim expired at retry boundary") | 67 | t.Fatal("claim expired at retry boundary") | ||
| 68 | } | 68 | } | ||
| 69 | - | c.placement.expireTerminals(t0.Add(termRetry + time.Nanosecond)) | 69 | + | c.placement.expireWorkspaceClaims(t0.Add(termRetry + time.Nanosecond)) |
| 70 | if _, ok := c.placement.pending[token]; ok { | 70 | if _, ok := c.placement.pending[token]; ok { | ||
| 71 | t.Fatal("claim survived past retry boundary") | 71 | t.Fatal("claim survived past retry boundary") | ||
| 72 | } | 72 | } | ||
M internal/core/terminal.go +5 -5
| @@ -23,7 +23,7 @@ func (c *Core) keepsTerminal() bool { | |||
| 23 | 23 | // Workspaces with slots are left to them. | |
| 24 | 24 | func (c *Core) fillEmpty() []ports.SpawnRequest { | |
| 25 | 25 | now := c.now() | |
| 26 | - | c.placement.expireTerminals(now) | |
| 26 | + | c.placement.expireWorkspaceClaims(now) | |
| 27 | 27 | if !c.keepsTerminal() { | |
| 28 | 28 | return nil | |
| 29 | 29 | } | |
| @@ -39,12 +39,12 @@ func (c *Core) fillEmpty() []ports.SpawnRequest { | |||
| 39 | 39 | continue | |
| 40 | 40 | } | |
| 41 | 41 | } | |
| 42 | - | if s.name() == "" || !w.empty() || c.hasSlots(w) || c.placement.terminalPending(w) || now.Sub(w.termAt) < termRetry { | |
| 42 | + | if s.name() == "" || !w.empty() || c.hasSlots(w) || c.placement.workspacePending(w, spawnTerminal) || now.Sub(w.termAt) < termRetry { | |
| 43 | 43 | continue | |
| 44 | 44 | } | |
| 45 | 45 | c.firstTerminalResolved = true | |
| 46 | 46 | w.termAt = now | |
| 47 | - | reqs = append(reqs, c.placement.request(c.cfg.Terminal.Command, spawnTarget{terminal: w, at: now})) | |
| 47 | + | reqs = append(reqs, c.placement.request(c.cfg.Terminal.Command, spawnTarget{kind: spawnTerminal, ws: w, at: now})) | |
| 48 | 48 | } | |
| 49 | 49 | return reqs | |
| 50 | 50 | } | |
| @@ -53,8 +53,8 @@ func (c *Core) fillEmpty() []ports.SpawnRequest { | |||
| 53 | 53 | func (c *Core) cancelTerminal(req ports.SpawnRequest) { | |
| 54 | 54 | for _, env := range req.Env { | |
| 55 | 55 | if token, ok := strings.CutPrefix(env, ports.SlotEnv+"="); ok { | |
| 56 | - | if target, ok := c.placement.pending[token]; ok && target.terminal != nil { | |
| 57 | - | target.terminal.termAt = time.Time{} | |
| 56 | + | if target, ok := c.placement.pending[token]; ok && target.kind == spawnTerminal { | |
| 57 | + | target.ws.termAt = time.Time{} | |
| 58 | 58 | delete(c.placement.pending, token) | |
| 59 | 59 | } | |
| 60 | 60 | } | |
| @@ -23,7 +23,7 @@ func (c *Core) keepsTerminal() bool { | |||||
| 23 | // Workspaces with slots are left to them. | 23 | // Workspaces with slots are left to them. | ||
| 24 | func (c *Core) fillEmpty() []ports.SpawnRequest { | 24 | func (c *Core) fillEmpty() []ports.SpawnRequest { | ||
| 25 | now := c.now() | 25 | now := c.now() | ||
| 26 | - | c.placement.expireTerminals(now) | 26 | + | c.placement.expireWorkspaceClaims(now) |
| 27 | if !c.keepsTerminal() { | 27 | if !c.keepsTerminal() { | ||
| 28 | return nil | 28 | return nil | ||
| 29 | } | 29 | } | ||
| @@ -39,12 +39,12 @@ func (c *Core) fillEmpty() []ports.SpawnRequest { | |||||
| 39 | continue | 39 | continue | ||
| 40 | } | 40 | } | ||
| 41 | } | 41 | } | ||
| 42 | - | if s.name() == "" || !w.empty() || c.hasSlots(w) || c.placement.terminalPending(w) || now.Sub(w.termAt) < termRetry { | 42 | + | if s.name() == "" || !w.empty() || c.hasSlots(w) || c.placement.workspacePending(w, spawnTerminal) || now.Sub(w.termAt) < termRetry { |
| 43 | continue | 43 | continue | ||
| 44 | } | 44 | } | ||
| 45 | c.firstTerminalResolved = true | 45 | c.firstTerminalResolved = true | ||
| 46 | w.termAt = now | 46 | w.termAt = now | ||
| 47 | - | reqs = append(reqs, c.placement.request(c.cfg.Terminal.Command, spawnTarget{terminal: w, at: now})) | 47 | + | reqs = append(reqs, c.placement.request(c.cfg.Terminal.Command, spawnTarget{kind: spawnTerminal, ws: w, at: now})) |
| 48 | } | 48 | } | ||
| 49 | return reqs | 49 | return reqs | ||
| 50 | } | 50 | } | ||
| @@ -53,8 +53,8 @@ func (c *Core) fillEmpty() []ports.SpawnRequest { | |||||
| 53 | func (c *Core) cancelTerminal(req ports.SpawnRequest) { | 53 | func (c *Core) cancelTerminal(req ports.SpawnRequest) { | ||
| 54 | for _, env := range req.Env { | 54 | for _, env := range req.Env { | ||
| 55 | if token, ok := strings.CutPrefix(env, ports.SlotEnv+"="); ok { | 55 | if token, ok := strings.CutPrefix(env, ports.SlotEnv+"="); ok { | ||
| 56 | - | if target, ok := c.placement.pending[token]; ok && target.terminal != nil { | 56 | + | if target, ok := c.placement.pending[token]; ok && target.kind == spawnTerminal { |
| 57 | - | target.terminal.termAt = time.Time{} | 57 | + | target.ws.termAt = time.Time{} |
| 58 | delete(c.placement.pending, token) | 58 | delete(c.placement.pending, token) | ||
| 59 | } | 59 | } | ||
| 60 | } | 60 | } | ||