Repository navigation
Expand file tree
/
Copy pathhost.go
More file actions
987 lines (930 loc) · 35.6 KB
/
Copy pathhost.go
File metadata and controls
987 lines (930 loc) · 35.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
package enginehost
// host.go is the host itself: the process that holds the conversations.
//
// It is a small thing on purpose. It owns a socket, a lock, a map of open
// sessions and a clock; everything about what a conversation IS belongs to
// internal/remote and internal/session, and everything about how one is
// ASSEMBLED belongs to the door that calls [Run]. A host that knew how to build
// an agent would be the second assembly this tree has already refused twice
// (cmd/codeaf's shared assembly states why).
import (
"errors"
"fmt"
"net"
"os"
"os/signal"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
"github.com/Agent-Field/codeaf/internal/filelock"
"github.com/Agent-Field/codeaf/internal/guard"
"github.com/Agent-Field/codeaf/internal/home"
"github.com/Agent-Field/codeaf/internal/remote"
)
// ErrHostRunning is [Run] finding that this workspace already has a host. It is
// not a failure and the caller must not print it: the machine is in exactly the
// state that was asked for, by another process.
var ErrHostRunning = errors.New("engine host: another host already holds this workspace")
// The idle policy, in three numbers.
//
// A CONVERSATION IS KEPT WHILE IT IS DOING ANYTHING AT ALL. Nobody attached is
// not idle: a turn still running, or a question waiting for somebody to come
// back and answer it, is the entire point of this process
// ([remote.Session.IdleSince] draws that line). What is idle is a conversation
// with no surface, no turn and no question — somebody finished and walked away
// — and it is kept for [sessionIdle] anyway, because "come back later and be in
// it" is the product, and reopening from the journal a minute after closing a
// laptop lid would be the version-1 experience with extra steps.
//
// AND THEN THE HOST LEAVES. With no conversations left and nobody attached
// there is nothing to be the host of, so it exits and the next `codeaf engine`
// starts a new one — which costs one process spawn and is invisible.
//
// AND THE AUTOMATIONS CLOCK IS NOT MARRIED TO IT, which is the one interaction
// worth stating. Automations run in `codeaf clock` while a window is open
// (cmd/codeaf's chatv3_clock.go), and this process's only part in that is to
// count an attached window as open for as long as it stays attached
// (internal/remote's automations.go). A host with nobody attached holds no
// window, so whether it stays up or leaves changes nothing the clock counts.
// sessionIdle is a var rather than a const so a test can ask what the policy
// DECIDES without waiting half an hour to find out. Nothing in the product
// writes it.
var sessionIdle = 30 * time.Minute
const hostIdle = 2 * time.Minute
// sweepEvery is how often the policy above looks: a product's half-minute, or a
// test's override, since a test watching a host take its quiet moment should not
// wait out a clock. It is atomic because one test sets it while another test's
// host may still be starting its own ticker; nothing in the product writes it.
var sweepEvery atomic.Int64
func sweepSpan() time.Duration {
if span := time.Duration(sweepEvery.Load()); span > 0 {
return span
}
return 30 * time.Second
}
// SessionIdleAfter is how long a host keeps a conversation nobody is in before
// it lets go of the conversation's journal lock, for a sentence that tells
// someone how long a "running" row can last.
func SessionIdleAfter() time.Duration { return sessionIdle }
// The two numbers a stand-down is measured in.
//
// A HOST THAT AGREED TO GO WRITES ITS ANSWER BEFORE IT GOES. The frame saying
// so has not left the process when the retirement is armed — the connection's
// own goroutine writes it on the way back out of the exchange — so the listener
// is closed only once every connection has hung up, and [standDownGrace] is the
// ceiling for one that never does. Two seconds because the only thing waiting
// on it is a local unix socket that is about to be dialled again.
const (
standDownGrace = 2 * time.Second
standDownPoll = 20 * time.Millisecond
)
// Options is what a host needs from the door that starts it, which is the same
// two things every version of this has needed: how to open a conversation, and
// which conversation a hello is asking for.
type Options struct {
// Boot opens one conversation for a hello — cmd/codeaf's bootEngine, the
// same closure the pipe engine hands to [remote.Serve].
//
// It is called with the host's own lock held, so ONE CONVERSATION IS OPENED
// AT A TIME. That is not for speed, it is for the session file: two
// connections asking for the same conversation at the same moment must not
// both open it, because the second would find the journal locked and mint a
// second session under a person who asked for one.
Boot func(remote.Hello) (*remote.Engine, error)
// Key says which conversation a hello wants, so that two surfaces asking
// for the same one are handed the same one.
//
// IT ANSWERS A TRANSCRIPT PATH, INCLUDING FOR A HELLO THAT NAMED NOTHING.
// "The workspace's latest" is a question about this machine's disk, and the
// door that starts the host is the half of this pair that can read it
// (cmd/codeaf's [engineHelloKey]) — resolving it here, at the door, is what
// lets the lookups below find a conversation this host is ALREADY holding
// rather than booting a second agent onto its journal.
//
// A nil Key falls back to the hello's own session, which is what a test with
// no door behind it means, and the empty string it answers for a hello that
// named nothing is then the boot's problem rather than an identity.
Key func(remote.Hello) string
// Cells is the cells mode this host runs in, fixed when it starts. It is
// told to every asker ([remote.HostSelf.Cells]) so a door in the other mode
// can replace an idle host or refuse a busy one instead of attaching.
Cells bool
// HomeRemoved ends the process when shutdown can no longer save into its
// original home. The command owns process exit; libraries only call this hook.
HomeRemoved func()
}
// Host is one workspace's conversations, and the socket they are reached
// through.
type Host struct {
workspace string
dir string
opts Options
listener net.Listener
lock *os.File
// binary is the file this host was started from, as it looked when it
// started. A host whose own binary has been replaced retires the moment it
// is holding nothing (binary.go states why).
binary hostBinary
// started is when this process began holding the workspace, for the one
// person who asks `codeaf engine --status` how long it has been there.
started time.Time
mu sync.Mutex
sessions map[string]*remote.Session
deleting map[string]bool
// latest is the transcript this host opened for the last hello that named
// no conversation, and it is an ALIAS RATHER THAN AN IDENTITY: nothing is
// ever filed under it, [Host.open] simply resolves the empty key through it
// before it looks anything up. See the law at that lookup.
latest string
// minted counts the conversations opened by a hello that asked for one of
// its own ([remote.Hello.New]) and could not be filed under their own
// transcript. It is bookkeeping and never an identity: see [Host.freeKeyLocked].
minted int
live int
// probes is how many of those live connections turned out to be the version
// exchange rather than a surface (whois.go). THE QUESTION MUST NOT COUNT AS
// THE WORK: a connection asking "are you busy" is not what busy means, and
// counting it would make every host answer yes to the one question the
// answer matters for.
probes int
// retiring is a stand-down that has been agreed to and not yet finished. It
// closes the door on new conversations, because a conversation opened into
// a process that is leaving is one the person watches vanish.
retiring bool
// wanted is a newer build asking for this slot without asking anybody to
// stop: the host keeps its work and retires at its first quiet moment
// ([Host.sweepOnce]) rather than waiting out the idle policy.
wanted bool
// quiet is when the host last had nothing to do, and zero while it has
// something. It is what [hostIdle] is measured against.
quiet time.Time
closed bool
done chan struct{}
}
// Run becomes this workspace's host and does not return until it is done: the
// idle policy retires it, a signal asks it to stop, or the listener breaks.
//
// It answers [ErrHostRunning] when another host already holds this workspace,
// which the caller treats as success — somebody else did the job.
func Run(workspace string, opts Options) error {
if opts.Boot == nil {
return errors.New("engine host: a host with no way to open a conversation")
}
dir, err := Dir(workspace)
if err != nil {
return err
}
socket, err := SocketPath(workspace)
if err != nil {
return err
}
// THE LOCK IS THE HOST'S LIFE AND NOT A MOMENT OF IT. It is taken before
// the socket exists and released only when this host stops serving, so "is
// there a host" is a question the operating system answers rather than a
// file somebody has to remember to delete.
lock, err := takeLock(filepath.Join(dir, lockName))
if err != nil {
return ErrHostRunning
}
// THE ENVIRONMENT IS RECORDED BEFORE THE SOCKET ANSWERS. Only the lock
// owner writes it, so a replacement cannot publish the previous host's
// fingerprint during its birth. A failed write leaves the answer unknown.
for _, name := range []string{environmentName, syncEnvironmentName, syncUnnamedName} {
_ = os.Remove(filepath.Join(dir, name))
}
_ = writeEnvironment(dir, workspace)
// Whatever socket file is lying there belongs to a host that is gone: the
// lock we just took proves it. A unix socket is not removed by the process
// that made it dying, so somebody has to, and the only safe moment to do it
// is with the lock in hand.
_ = os.Remove(socket)
listener, err := net.Listen("unix", socket)
if err != nil {
_ = releaseLock(lock)
return fmt.Errorf("engine host: %w", err)
}
// The readable half of the directory's name, for a person looking at
// ~/.codeaf/v3/hosts and wondering which of these is which.
_ = os.WriteFile(filepath.Join(dir, placeName), []byte(workspace+"\n"), 0o600)
h := &Host{
workspace: workspace,
dir: dir,
opts: opts,
binary: thisBinary(),
started: time.Now(),
listener: listener,
lock: lock,
sessions: map[string]*remote.Session{},
quiet: time.Now(),
done: make(chan struct{}),
}
return h.serve()
}
func (h *Host) serve() error {
// A machine going down should flush journals rather than lose the last
// turn, so the two signals a shutdown actually arrives as are answered the
// same way a retirement is.
signals := make(chan os.Signal, 1)
signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM)
defer signal.Stop(signals)
go func() {
select {
case <-signals:
h.stop()
case <-h.done:
}
}()
guard.Go("enginehost/sweep", h.sweep)
guard.Go("enginehost/doorstep", h.doorstep)
for {
conn, err := h.listener.Accept()
if err != nil {
if h.stopped() {
break
}
// A listener that broke while the host still wanted it is the end
// of this host: there is no way back to accepting, and everything
// attached is about to lose its socket anyway.
h.note("stopped accepting: " + err.Error())
break
}
h.mu.Lock()
h.live++
h.quiet = time.Time{}
h.mu.Unlock()
guard.Go("enginehost/connection", func() { h.attach(conn) })
}
h.shutdown()
return nil
}
// attach serves one connection. The frames are internal/remote's from the first
// byte — this side contributes only the answer to "which conversation".
func (h *Host) attach(conn net.Conn) {
// asked is this connection turning out to be the version exchange and not a
// surface. It is written by the closure below and read after ServeAttach
// has returned, both on THIS goroutine, which is the whole of why it needs
// no lock of its own.
asked := false
defer func() {
_ = conn.Close()
h.mu.Lock()
h.live--
if asked {
h.probes--
}
if h.live == 0 && len(h.sessions) == 0 {
h.quiet = time.Now()
}
h.mu.Unlock()
}()
if err := remote.ServeAttach(conn, conn, remote.AttachOptions{
Open: h.open,
Host: func(ask remote.WhoIs) remote.HostSelf {
asked = true
return h.whois(ask)
},
}); err != nil {
// A host has no terminal and no person to tell, so a broken connection
// goes to a file an operator can read afterwards — the destination, and
// the reason, the automations clock's failures have too.
h.note("connection: " + err.Error())
}
}
// open answers which conversation a hello is attaching to: the one already
// running under that key, or a new one.
func (h *Host) open(hello remote.Hello) (*remote.Session, error) {
key := strings.TrimSpace(hello.Session)
if h.opts.Key != nil {
key = h.opts.Key(hello)
}
h.mu.Lock()
defer h.mu.Unlock()
if h.closed || h.retiring {
return nil, errors.New("engine host: this host is shutting down")
}
if key == "" && !hello.New {
key = h.latest
}
if h.deleting[deletionKey(hello.Session)] || h.deleting[deletionKey(key)] {
return nil, errors.New("this conversation is being permanently deleted")
}
// A JOIN TAKES A CONVERSATION THAT IS ALREADY HERE AND NOTHING ELSE. It is
// matched on the transcript rather than on the key, and it never reaches the
// boot below — [remote.Hello.Join] says why both halves of that are the point.
if hello.Join {
return h.joinedLocked(hello.Session)
}
// AND A MINT TAKES NOTHING THAT IS ALREADY HERE. [remote.Hello.New] is a
// window opening ANOTHER conversation beside the ones it has, so the lookup
// below — which is what makes two surfaces saying nothing land in one
// conversation — is exactly what it must not do. It boots, and it is keyed by
// the transcript the engine chose rather than by the empty string the hello
// carried, so a later window naming that file finds this conversation instead
// of opening a second agent onto the same journal.
if hello.New {
return h.mintedLocked(hello)
}
// AND NO CONVERSATION IS EVER KEYED BY THE EMPTY STRING, which is the whole
// of the collision this closes.
//
// "Nothing" is not a name: a hello that names no session is asking for this
// workspace's LATEST, and the door resolves that to a transcript path before
// it ever reaches here (cmd/codeaf's [engineHelloKey]). What is left for the
// host is the one case a door cannot resolve — a workspace with no
// conversation in it yet, and a host with no door at all — and there the
// empty string is resolved through the transcript the last such hello landed
// on rather than used as a slot to file under.
//
// It was a slot once, and it worked exactly as long as that slot held the
// workspace's latest. The moment its conversation ended — moved to another
// window, left behind by /new, closed — while this host went on holding a
// different one, the next plain hello found nothing under "" and booted;
// the boot resolved the workspace's latest, which is a journal THIS PROCESS
// holds the flock on, and the host refused its own conversation with "this
// conversation is open in another window".
if key == "" {
key = h.latest
}
if existing := h.sessions[key]; existing != nil && !existing.Ended() && !deletedJournal(existing.File()) {
// THE WHOLE PRODUCT IS THIS LINE: the conversation was already running,
// possibly mid-turn, and the surface is joining it rather than starting
// anything.
return existing, nil
}
// AND A HELLO THAT NAMES A TRANSCRIPT THIS HOST IS ALREADY HOLDING IS THE
// SAME LINE SAID ABOUT A DIFFERENT KEY. A conversation minted by the branch
// above, or by an older hello that named no session, is keyed by something
// this hello has no way to guess — so without this a person reopening that
// chat by its own path would boot a second agent onto a journal the first one
// holds the lock on, and be told their conversation was open in another
// window by the process they were talking to.
if open, found := h.openLocked(key); found {
return open, nil
}
engine, err := h.opts.Boot(hello)
if err != nil {
return nil, err
}
if engine == nil || engine.Agent == nil {
return nil, errors.New("engine host: the workspace opened no conversation")
}
h.wrapDeletion(engine)
sess := remote.NewSession(engine, true)
// The conversation is filed under the JOURNAL THE BOOT ACTUALLY OPENED,
// which is the one name every later hello can arrive at — by naming it, or
// by resolving "this workspace's latest" to it.
filed := h.freeKeyLocked(firstFilled(engine.SessionFile, key))
h.sessions[filed] = sess
if key == "" {
// And this is what a hello that named nothing will mean next time. It
// is the alias above, written where the answer is finally known — the
// name this conversation was actually filed under, so that an engine
// which answered no transcript at all is still found rather than booted
// a second time.
h.latest = filed
}
h.quiet = time.Time{}
return sess, nil
}
// firstFilled is the first of its arguments with something in it, trimmed.
func firstFilled(words ...string) string {
for _, word := range words {
if trimmed := strings.TrimSpace(word); trimmed != "" {
return trimmed
}
}
return ""
}
// mintedLocked opens a conversation of this host's own choosing and files it
// under the transcript it opened.
//
// THE KEY IS THE ENGINE'S ANSWER AND NOT THE HELLO'S QUESTION, which is the one
// thing that makes this different from the ordinary road. A minting hello names
// no session on purpose — where a new conversation lands is a question about
// this machine's disk — so keying it by what the hello said would file every
// minted conversation under the empty string and have the second one replace the
// first.
//
// IT IS ALWAYS FILED, even when the transcript it wants is somehow taken and
// even when the engine answered no file at all. The map is not only the door a
// second window comes back through: it is what the sweep retires conversations
// out of and what [Host.idleLocked] counts, so a session left out of it would be
// a conversation this host holds, keeps alive and can never let go of.
func (h *Host) mintedLocked(hello remote.Hello) (*remote.Session, error) {
engine, err := h.opts.Boot(hello)
if err != nil {
return nil, err
}
if engine == nil || engine.Agent == nil {
return nil, errors.New("engine host: the workspace opened no conversation")
}
h.wrapDeletion(engine)
sess := remote.NewSession(engine, true)
h.sessions[h.freeKeyLocked(engine.SessionFile)] = sess
h.quiet = time.Time{}
return sess, nil
}
// freeKeyLocked is where a minted conversation is filed: its own transcript when
// nothing live is under that name, and otherwise a key nothing can collide with.
// The fallback is not reachable by name and is not meant to be — it exists so
// that the bookkeeping above can never drop a conversation on the floor.
func (h *Host) freeKeyLocked(file string) string {
key := strings.TrimSpace(file)
if key != "" {
if held := h.sessions[key]; held == nil || held.Ended() {
return key
}
}
h.minted++
return fmt.Sprintf("\x00minted-%d", h.minted)
}
// openLocked is the live conversation writing one transcript, found by that
// transcript rather than by the key it was filed under. It is [Host.joinedLocked]
// without the refusal: a caller here has somewhere else to go when the answer is
// no, which is the boot.
func (h *Host) openLocked(file string) (*remote.Session, bool) {
want := strings.TrimSpace(file)
if want == "" {
return nil, false
}
want = filepath.Clean(want)
for _, sess := range h.sessions {
if sess == nil || sess.Ended() || deletedJournal(sess.File()) {
continue
}
if open := strings.TrimSpace(sess.File()); open != "" && filepath.Clean(open) == want {
return sess, true
}
}
return nil, false
}
// joinedLocked is the live conversation writing one transcript, and an error
// naming what was asked for when there is none.
//
// THE REFUSAL IS A SENTENCE AND NOT A BOOT. The caller is a surface that wants a
// second view onto work it believes is running; if that belief is stale — the
// window closed a second ago, the conversation ended — the honest answer is that
// it is not here, and the surface has a recovery state for exactly that. Starting
// a conversation would answer a question nobody asked with a model somebody pays
// for.
//
// The file is compared cleaned, because one side of this walked a directory and
// the other read a presence file, and neither promises the other's spelling.
func (h *Host) joinedLocked(file string) (*remote.Session, error) {
want := strings.TrimSpace(file)
if want == "" {
return nil, errors.New("engine host: a join has to name a conversation")
}
if sess, found := h.openLocked(want); found {
return sess, nil
}
return nil, fmt.Errorf("engine host: that conversation is not open here: %s", filepath.Clean(want))
}
// whois is the host answering what it is and what it will do about a request to
// go — the host's half of internal/remote's version exchange.
//
// THE HOST DECIDES, AND THAT IS THE WHOLE OF WHY THIS IS SAFE. The process
// asking cannot see a turn in flight, a surface in another window or a card
// waiting for an answer; this one can, and a host holding any of them says so
// and stays exactly where it is. Nothing on the asking side can end somebody
// else's work by accident, because nothing on the asking side does the ending.
func (h *Host) whois(ask remote.WhoIs) remote.HostSelf {
h.mu.Lock()
// This connection is the question and not the work — counted before
// anything is measured, so that the measurement is right.
h.probes++
self := remote.HostSelf{Workspace: h.workspace, Busy: !h.idleWithWatchGraceLocked(ask.StandDown && ask.IgnoreWatchGrace)}
// Recorded under the lock that is about to measure whether this host may
// go. It is a note and never a verdict: nothing about the work in flight
// changes, and the answer acknowledges it so the asker can tell this host
// apart from one too old to have heard of it.
if ask.StandDownWhenIdle {
h.wanted, self.StandDownWhenIdle = true, true
}
// AND WHAT THIS PROCESS IS, for `codeaf engine --status` and for the door
// deciding which of two builds is the older one. The counts are read under
// the same lock as busy, so the three never disagree with each other.
self.PID = os.Getpid()
self.Binary = h.binary.path
self.Started = h.started
self.BuiltAt = h.binary.builtAt()
self.Surfaces = h.live - h.probes
self.Cells = h.opts.Cells
for _, sess := range h.sessions {
if sess != nil && !sess.Ended() && !deletedJournal(sess.File()) {
self.Conversations++
}
}
going := ask.StandDown && (ask.Anyway || !self.Busy)
switch {
case h.closed || h.retiring:
// Already leaving, which is the answer the asker wanted either way.
self.Retiring = true
going = false
case going:
h.retiring, self.Retiring = true, true
}
h.mu.Unlock()
if going {
h.standDown()
}
return self
}
// idleLocked is this host holding nothing: no connection attached that is not
// the question itself, and no conversation with a surface, a running turn or a
// question waiting to be answered ([remote.Session.IdleSince] draws that last
// line and is the same reading the sweep uses).
//
// IT IS NOT THE IDLE POLICY. The clocks above are about a host that has been
// quiet for LONG ENOUGH to be worth retiring; this is about a host that could
// be retired RIGHT NOW without taking anything down with it, which is a
// different question with a different answer.
func (h *Host) idleLocked() bool {
return h.idleWithWatchGraceLocked(false)
}
func (h *Host) idleWithWatchGraceLocked(ignoreWatchGrace bool) bool {
if h.live-h.probes > 0 {
return false
}
for _, sess := range h.sessions {
// ENDED CONVERSATIONS HOLD NOTHING. Their last act can still be inside
// the watch grace before the sweep removes them from this map.
if sess == nil || sess.Ended() {
continue
}
idle := sess.IdleSince
if ignoreWatchGrace {
idle = sess.IdleSinceWithoutWatchGrace
}
if idle().IsZero() {
return false
}
}
return true
}
// standDown ends the host once the connections it is talking to have gone.
//
// The wait is what makes the answer arrive: the frame agreeing to retire is
// written by the asking connection's own goroutine after this returns, so a
// listener closed here would close it under the sentence it was carrying.
func (h *Host) standDown() {
guard.Go("enginehost/standdown", func() {
deadline := time.Now().Add(standDownGrace)
for {
h.mu.Lock()
empty := h.live == 0
h.mu.Unlock()
if empty || time.Now().After(deadline) {
break
}
time.Sleep(standDownPoll)
}
h.stop()
})
}
// sweep is the idle policy, run on a clock: retire the conversations nobody
// wants any more, and then retire the host when there is nothing left to hold.
func (h *Host) sweep() {
ticker := time.NewTicker(sweepSpan())
defer ticker.Stop()
for {
select {
case <-h.done:
return
case <-ticker.C:
}
if h.sweepOnce() {
h.stop()
return
}
}
}
// sweepOnce is one pass of the policy, and it answers whether this was the host's
// last one. It is a function rather than the body of the loop above so that a
// test can ask what the policy decides without waiting out a clock.
func (h *Host) sweepOnce() bool {
// THE DECISION IS MADE OFF THIS HOST'S LOCK. Retiring a conversation flushes
// its journal and may walk its graph, and doing that with the sessions map
// held would stall every connection arriving meanwhile.
type held struct {
key string
sess *remote.Session
}
h.mu.Lock()
holding := make([]held, 0, len(h.sessions))
for key, sess := range h.sessions {
holding = append(holding, held{key: key, sess: sess})
}
h.mu.Unlock()
var gone []held
for _, one := range holding {
// RetireIfIdle takes its last look and ends the conversation under one
// acquisition of the session's own lock, so a surface that arrived while
// this pass was thinking cancels the retirement rather than losing the
// conversation it just joined.
if one.sess.Ended() || one.sess.RetireIfIdle(sessionIdle) {
gone = append(gone, one)
}
}
h.mu.Lock()
for _, one := range gone {
// Only if it is still the SAME conversation under that key: a hello may
// have opened a fresh one there while this pass was retiring the old.
if h.sessions[one.key] == one.sess {
delete(h.sessions, one.key)
}
}
if h.live == 0 && len(h.sessions) == 0 && h.quiet.IsZero() {
h.quiet = time.Now()
}
leaving := h.live == 0 && len(h.sessions) == 0 &&
!h.quiet.IsZero() && time.Since(h.quiet) > hostIdle
replaced := !leaving && h.binary.replaced() && h.idleLocked()
// THE QUIET MOMENT A NEWER BUILD ASKED FOR, decided under the same lock the
// retirement is set under — so a conversation that arrived since the note
// keeps this host alive as surely as it keeps it from being asked to go.
//
// It ignores the watch grace, as an environment replacement does
// ([remote.WhoIs.IgnoreWatchGrace]): that grace covers a stalled window
// about to redial, and a redial is what this swap is safe under, because
// the client at the other end is whatever binary is on disk now. Nothing
// real is ignored — a surface, a turn, a waiting question, handed-off
// work each still holds this host.
waited := !leaving && !replaced && h.wanted && h.idleWithWatchGraceLocked(true)
if replaced || waited {
// The door closes under the same lock that decided, so a surface
// arriving in the moment between here and the stop is refused rather
// than handed a conversation that is about to end.
leaving, h.retiring = true, true
}
h.mu.Unlock()
if replaced {
h.note("the file this host was started from has been replaced; retiring so the next connection starts the current one")
}
if waited {
h.note("a newer codeaf asked for this workspace; it is holding nothing now, so the next connection starts from the current build")
}
return leaving
}
// takeoverDoorstep is how often the host looks for a conversation another
// window has asked for. The agent inside already looks at its own doorstep four
// times a second (internal/session's takeover.go); this is one lock and a walk
// of a small map, at half that rate, so a window waiting on the journal's flock
// sees it free within about a second of asking.
const takeoverDoorstep = 500 * time.Millisecond
// doorstep is the host honouring a move-it-here request for a conversation it
// holds (internal/remote's takeover.go says why the host has to be the one that
// looks): the conversation asked for is closed, which releases its journal to
// the window that asked.
func (h *Host) doorstep() {
ticker := time.NewTicker(takeoverDoorstep)
defer ticker.Stop()
for {
select {
case <-h.done:
return
case <-ticker.C:
}
h.releaseAsked()
}
}
// releaseAsked is one look. THE CLOSE HAPPENS OFF THE HOST'S LOCK, for the
// sweep's reason: closing a conversation flushes its journal, and every
// connection arriving meanwhile would stall behind it.
func (h *Host) releaseAsked() {
type asked struct {
key string
sess *remote.Session
}
h.mu.Lock()
holding := make([]asked, 0, len(h.sessions))
for key, sess := range h.sessions {
if sess != nil {
holding = append(holding, asked{key: key, sess: sess})
}
}
h.mu.Unlock()
// The agents are asked off the host's lock too: each answer takes the
// agent's own lock, and nothing here may queue the host behind a turn.
var found []asked
for _, one := range holding {
if one.sess.TakeoverAsked() {
found = append(found, one)
}
}
for _, one := range found {
_ = one.sess.ReleaseForTakeover()
h.note("let go of " + one.sess.File() + ": another window on this machine asked for it")
}
if len(found) == 0 {
return
}
h.mu.Lock()
for _, one := range found {
if h.sessions[one.key] == one.sess {
delete(h.sessions, one.key)
}
}
if h.live == 0 && len(h.sessions) == 0 && h.quiet.IsZero() {
h.quiet = time.Now()
}
h.mu.Unlock()
}
// stop asks the host to end. It is idempotent because the signal handler, the
// sweep and a caller may all reach it.
func (h *Host) stop() {
h.mu.Lock()
if h.closed {
h.mu.Unlock()
return
}
h.closed = true
h.mu.Unlock()
close(h.done)
// Closing the listener is what wakes the accept loop, which is where the
// shutdown actually happens.
_ = h.listener.Close()
}
func (h *Host) stopped() bool {
h.mu.Lock()
defer h.mu.Unlock()
return h.closed
}
// shutdown is the exit: the workspace is handed over, and then every
// conversation is closed, which flushes every journal.
//
// THE SOCKET AND THE LOCK GO FIRST, BEFORE ANY CONVERSATION IS SAVED. Closing a
// conversation waits for its last save, and in a large folder that save can
// take the whole of its bound. A host holding the lock through it left the next
// launch nothing to do but wait for a socket nobody was going to open, and then
// fall back to a conversation in its own process. The lock says who serves this
// workspace, and from here on that is nobody; each conversation still holds its
// own journal lock until its save is done, so the next host cannot open a
// conversation that is still being written.
//
// The socket is removed while the lock is still held, so the path removed is
// always this host's own and never a successor's.
//
// THE SAVES ARE STILL VISIBLE. The saving lock is taken before the workspace
// lock is let go and released after the last conversation is closed, so a
// caller that asked this host to go can wait a moment for the journals it is
// about to reopen ([waitForSaves]) while a launch that only needs the slot does
// not wait at all. A second host already saving holds it too; this one then
// saves without it.
func (h *Host) shutdown() {
if h.opts.HomeRemoved != nil {
root := home.Dir()
if home.Removed(root) {
h.opts.HomeRemoved()
return
}
finished := make(chan struct{})
defer close(finished)
go func() {
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-finished:
return
case <-ticker.C:
if home.Removed(root) {
h.opts.HomeRemoved()
return
}
}
}
}()
}
h.mu.Lock()
h.closed = true
sessions := make([]*remote.Session, 0, len(h.sessions))
for key, sess := range h.sessions {
sessions = append(sessions, sess)
delete(h.sessions, key)
}
h.mu.Unlock()
saving, _ := takeLock(filepath.Join(h.dir, savingName))
socket := filepath.Join(h.dir, socketName)
_ = h.listener.Close()
_ = os.Remove(socket)
_ = releaseLock(h.lock)
for _, sess := range sessions {
_ = sess.Close()
}
_ = releaseLock(saving)
}
// note appends one line to the host's log. It is best-effort by construction:
// a host that could not write its log is still a host, and there is nobody to
// tell about the failure to tell somebody.
func (h *Host) note(line string) {
file, err := os.OpenFile(filepath.Join(h.dir, logName), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil {
return
}
defer file.Close()
fmt.Fprintf(file, "%s %s\n", time.Now().Format(time.RFC3339), line)
}
// ── the lock ────────────────────────────────────────────────────────────────
// takeLock is the tree's own advisory lock, taken without blocking: a busy lock
// is an answer ("somebody else has this") and never a wait. internal/filelock
// is the one primitive for this in the tree and nothing here invents a second.
func takeLock(path string) (*os.File, error) {
file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
if err != nil {
return nil, err
}
if err := filelock.Lock(file, true, true); err != nil {
_ = file.Close()
return nil, err
}
return file, nil
}
func releaseLock(file *os.File) error {
if file == nil {
return nil
}
err := filelock.Unlock(file)
if closeErr := file.Close(); err == nil {
err = closeErr
}
return err
}
// Deletion excludes new joins to the same journal while its owner is stopped.
// Sessions are closed outside the host lock so unrelated work stays reachable.
func deletionKey(file string) string {
if file == "" {
return ""
}
if resolved, err := filepath.EvalSymlinks(file); err == nil {
return resolved
}
return filepath.Clean(file)
}
func (h *Host) deleteConversation(file string, choices map[string]string, affected map[string][]string, remove func(string, map[string]string, map[string][]string) error) error {
key := deletionKey(file)
var owners []*remote.Session
err := func() error {
h.mu.Lock()
defer h.mu.Unlock()
if h.deleting == nil {
h.deleting = map[string]bool{}
}
if h.deleting[key] {
return errors.New("this conversation is already being deleted")
}
h.deleting[key] = true
for _, sess := range h.sessions {
if deletionKey(sess.File()) == key {
owners = append(owners, sess)
}
}
return nil
}()
if err != nil {
return err
}
defer func() { h.mu.Lock(); defer h.mu.Unlock(); delete(h.deleting, key) }()
if err := remove(file, choices, affected); err != nil {
return err
}
for _, sess := range owners {
if err := sess.Close(); err != nil {
return err
}
}
func() {
h.mu.Lock()
defer h.mu.Unlock()
for name, sess := range h.sessions {
for _, owner := range owners {
if sess == owner {
delete(h.sessions, name)
}
}
}
if deletionKey(h.latest) == key {
h.latest = ""
}
}()
return nil
}
// Every boot door uses the same deletion reservation and cache retirement.
func (h *Host) wrapDeletion(engine *remote.Engine) {
if remove := engine.DeleteConversation; remove != nil {
engine.DeleteConversation = func(file string, choices map[string]string, affected map[string][]string) error {
return h.deleteConversation(file, choices, affected, remove)
}
}
}
// The engine owns tombstones; the host checks them before returning a cached view.
func deletedJournal(file string) bool {
if file == "" {
return false
}
_, err := os.Stat(filepath.Join(filepath.Dir(file), ".conversation-deleted"))
return err == nil
}