wsb 0.253.6349

Workspace tools for every project under $PRJ: git across submodules, template stamping, GitHub, and the workbench
Documentation
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
//! Starting the daemon, and being sure it is the daemon this wsb expects.

use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};

use super::client::Client;
use super::protocol::Info;
use super::machine::MachineRendezvous;

/// How long to wait for a daemon that has just been started to answer.
///
/// It opens the workspace database, registers the workspace it was started in
/// — installing that workspace's file watchers — and mounts the workbench
/// before it publishes its rendezvous file, so this covers a cold start on a
/// slow disk. A daemon that has not answered by then has failed: it is
/// stopped, and its log says why — which is what the caller is told to read.
const START_TIMEOUT: Duration = Duration::from_secs(20);

/// How often to look for the rendezvous file while waiting.
const START_POLL: Duration = Duration::from_millis(50);

/// How long to wait for a daemon that has been told to stop to be gone.
///
/// It has already answered the request by then; what remains is releasing the
/// port and unpublishing itself, which is quick unless it is finishing work.
const STOP_TIMEOUT: Duration = Duration::from_secs(20);

/// How long a daemon that is still serving — its process alive, its port held
/// — is waited for when it does not answer. Under heavy load it answers late,
/// not never.
const BUSY_TIMEOUT: Duration = Duration::from_secs(60);

/// The daemon's log, in the state directory beside its rendezvous file.
pub fn log_path(state_dir: &Path) -> PathBuf {
    state_dir.join("daemon.log")
}

/// What a daemon is started WITH.
///
/// Applies only to one this command actually starts. A daemon already running
/// keeps the settings it was started with — changing them would mean stopping
/// it, and stopping a process holding an open store because somebody passed a
/// different `--port` is not a thing a command should do quietly.
#[derive(Debug, Clone)]
pub struct Spawn {
    /// The address to serve the workbench on.
    ///
    /// Loopback unless an operator asked for something else. The workbench has
    /// no authentication, so this is the difference between "my machine" and
    /// "every machine that can route to mine" — and it is a decision somebody
    /// makes per start, not a preference that persists into a daemon nobody
    /// remembers configuring.
    pub listen: std::net::IpAddr,
    /// The port the workbench and the control API are served on.
    pub http_port: Port,
    /// A process the daemon should not outlive, when something embeds it.
    pub parent_pid: Option<u32>,
    /// Whether the daemon logs what the workbench is doing.
    pub debug: bool,
    /// The port peers dial. Zero turns peer listening off entirely, which is
    /// what a machine that is not part of a peer relationship wants.
    pub peer_port: u16,
}

impl Default for Spawn {
    /// What a command that just needs a daemon asks for.
    ///
    /// Loopback, the first free workbench port from the default and the
    /// standard peer port. Stated as a default because
    /// most callers have no opinion — and the ones that do (`wsb serve`) say
    /// so explicitly rather than mutating one of these.
    fn default() -> Self {
        Self {
            listen: super::LOOPBACK,
            http_port: Port::FirstFreeFrom(super::DEFAULT_HTTP_PORT),
            parent_pid: None,
            debug: false,
            peer_port: crate::peer::DEFAULT_PEER_PORT,
        }
    }
}

/// Which port a daemon this command starts should be served on.
///
/// One daemon per workspace, and an operator has several workspaces — so a
/// number fixed for all of them makes the second workspace's daemon collide
/// with the first's and fail to bind. Where the operator has not named one,
/// the daemon takes the first free port from the default and publishes it in
/// its rendezvous file; `wsb daemon status` says which.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Port {
    /// The operator named it. Taken means refused: they asked for THAT port,
    /// and quietly serving somewhere else is not an answer to what they asked.
    Exact(u16),
    /// Nobody named one. The first free port from here.
    FirstFreeFrom(u16),
}

/// How many ports to try before giving up on finding a free one.
///
/// A machine with sixteen consecutive ports taken from the default is one with
/// something else going on, and walking further would eventually put a
/// workbench somewhere nobody would think to look.
const PORT_ATTEMPTS: u16 = 16;

impl Port {
    /// The port to hand the daemon.
    ///
    /// A probe, not a reservation: this binds to see whether the port is free
    /// and lets go, so the daemon can bind it a moment later. Losing that race
    /// to another process is possible and is not papered over — the daemon
    /// fails to bind, says so, and this command reports what it said.
    ///
    /// Probed on the address the daemon will actually serve on. A port free on
    /// loopback is not necessarily free on every interface, and a probe that
    /// asked a different question than the bind would answer "free" and then
    /// fail to start.
    fn resolve(self, listen: std::net::IpAddr) -> Result<u16, String> {
        match self {
            Self::Exact(port) => Ok(port),
            Self::FirstFreeFrom(first) => {
                for offset in 0..PORT_ATTEMPTS {
                    let port = first.saturating_add(offset);
                    if std::net::TcpListener::bind(std::net::SocketAddr::new(listen, port)).is_ok()
                    {
                        return Ok(port);
                    }
                }
                Err(format!(
                    "no free port for the workbench between {first} and {}; \
                     name one with `wsb serve --port`",
                    first.saturating_add(PORT_ATTEMPTS - 1)
                ))
            }
        }
    }
}

/// Whether the daemon described by `stopping` is gone.
///
/// Both halves, because either alone is a half-state: the rendezvous file no
/// longer names this process, AND the port it served can be bound again. A
/// daemon that has unpublished itself but still holds the port is one a
/// replacement would fail to bind against.
fn is_stopped(state_dir: &Path, stopping: &Info) -> Result<bool, String> {
    let unpublished = match MachineRendezvous::read(state_dir)? {
        Some(rendezvous) => rendezvous.pid != stopping.pid,
        None => true,
    };
    if !unpublished {
        return Ok(false);
    }
    let address = std::net::SocketAddr::new(
        super::client::dialable(stopping.http_address),
        stopping.http_port,
    );
    Ok(std::net::TcpListener::bind(address).is_ok())
}

/// Wait until a daemon that was told to stop has actually stopped.
///
/// `shutdown` is answered BEFORE the process ends — it has to be, or the
/// reply would race the socket closing — so "the daemon acknowledged" and
/// "the daemon is gone" are two different moments. A restart that starts a
/// new daemon at the first moment starts it while the old one still holds the
/// port and still answers on it: the new daemon fails to bind, the rendezvous
/// file still names the old one, and the command talks to a process that is
/// on its way out. That is what `resolve` timing out looked like.
///
/// Gone means both halves, because either alone is a half-state: the
/// rendezvous file no longer names this process, AND the port it served can
/// be bound again.
pub fn wait_until_stopped(state_dir: &Path, stopping: &Info) -> Result<(), String> {
    let address = std::net::SocketAddr::new(
        super::client::dialable(stopping.http_address),
        stopping.http_port,
    );
    let deadline = Instant::now() + STOP_TIMEOUT;
    loop {
        if is_stopped(state_dir, stopping)? {
            return Ok(());
        }
        if Instant::now() >= deadline {
            return Err(format!(
                "wsb-daemon (pid {}) accepted the request to stop but is still running \
                 {}s later, and {address} is still its port.\n  \
                 its log is in {}",
                stopping.pid,
                STOP_TIMEOUT.as_secs(),
                log_path(state_dir).display()
            ));
        }
        std::thread::sleep(START_POLL);
    }
}

/// The daemon for this MACHINE, started if it is not already running.
///
/// The sequence is: believe the rendezvous file only if the port it names
/// answers; if it does not, the file is stale and is replaced; if the daemon
/// that answers is a different version of wsb, refuse it rather than speak to
/// it. Every one of those is a state that happens in ordinary use — a reboot,
/// a `git pull` and a rebuild — and none of them is a state to carry on from
/// with a guess.
///
/// The third value is whether THIS call started it, so a caller can say
/// "started" rather than guess between that and "already running".
pub fn ensure_running(
    state_dir: &Path,
    workspace_root: &Path,
    spawn_with: &Spawn,
) -> Result<(Client, Info, bool), String> {
    if let Some(existing) = MachineRendezvous::read(state_dir)? {
        let client = Client::from_rendezvous(&existing)?;
        if let Some(info) = answer_of(&client, &existing, BUSY_TIMEOUT)? {
            require_same_version(&info)?;
            // The worktree is resolved rather than required to match: one
            // daemon serves the machine, and this command's worktree is
            // whichever one contains the directory it was run in.
            client.resolve_worktree(workspace_root)?;
            return Ok((client, info, false));
        }
        // Whatever wrote the file is gone. Replaced rather than reported,
        // because a machine that has been rebooted is not a machine with a
        // problem — but only if the file still names it.
        MachineRendezvous::remove_if_published_by(state_dir, existing.pid)?;
    }

    let (child, log_was) = spawn(state_dir, workspace_root, spawn_with)?;
    let (client, info) = wait_for_daemon(state_dir, workspace_root, child, log_was, START_TIMEOUT)?;
    Ok((client, info, true))
}

/// What the published daemon answers: `Some` when it answers, `None` when it
/// is gone, and an error when it is serving and will not answer.
///
/// A port that is slow is not a port that is dead. Under a load average of
/// five hundred the live daemon answered too late, every command took it for
/// gone, removed its file and started another, and the last of those to exit
/// left the machine with a healthy daemon and no file naming it. A daemon
/// that does not answer is waited for while it is still serving, and refused
/// as busy if it never does — never started beside.
fn answer_of(client: &Client, existing: &MachineRendezvous, wait: Duration) -> Result<Option<Info>, String> {
    let deadline = Instant::now() + wait;
    loop {
        if let Ok(info) = client.info() {
            return Ok(Some(info));
        }
        if !still_serving(existing) {
            return Ok(None);
        }
        if Instant::now() >= deadline {
            return Err(format!(
                "wsb-daemon (pid {}) is serving this machine on port {} and has not answered for \
                 {}s: it is busy, not gone. Try again when the machine is less loaded, or \
                 restart it:\n      wsb daemon restart",
                existing.pid,
                existing.http_port,
                wait.as_secs()
            ));
        }
        std::thread::sleep(START_POLL);
    }
}

/// Whether the daemon a rendezvous names is still there: its process alive
/// AND its port held.
///
/// Both, because either alone misleads. A pid outlives nothing across a reboot
/// — another process may have it — and a port can be held by something else;
/// a daemon that is serving has both.
pub fn still_serving(published: &MachineRendezvous) -> bool {
    let address = std::net::SocketAddr::new(super::client::dialable(published.http_address), published.http_port);
    crate::ops::process::is_alive(published.pid) && std::net::TcpListener::bind(address).is_err()
}

/// The daemon this wsb belongs to: the executable beside it.
///
/// Not searched for on PATH. A PATH lookup finds whichever daemon is installed
/// rather than the one built from this tree, and the two share a database
/// schema and a control protocol — so a mismatch there is silent damage rather
/// than a missing file.
pub fn daemon_executable() -> Result<PathBuf, String> {
    beside_me(Half::Daemon)
}

/// The command line this daemon belongs to: the executable beside it.
///
/// The mirror of [`daemon_executable`], and for the same reason. The daemon
/// runs every operation as a child `wsb` — that is what makes a button and a
/// command the same thing — and while the server lived INSIDE the wsb binary,
/// `current_exe()` was that program. It is not any more: the server is its own
/// binary, and a daemon spawning itself with `test --edition website` answers
/// "unknown argument: test" to every operation started from a page.
///
/// Not searched for on PATH, for the reason `daemon_executable` is not: the
/// pair is installed together and shares a database schema, and the one on
/// PATH is whichever was installed last.
pub fn cli_executable() -> Result<PathBuf, String> {
    beside_me(Half::Cli)
}

/// Which half of the pair is being looked for.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Half {
    Cli,
    Daemon,
}

impl Half {
    fn file_name(self) -> &'static str {
        match (self, cfg!(windows)) {
            (Self::Cli, false) => "wsb",
            (Self::Cli, true) => "wsb.exe",
            (Self::Daemon, false) => "wsb-daemon",
            (Self::Daemon, true) => "wsb-daemon.exe",
        }
    }
}

/// The named half, in the directory this executable is in.
fn beside_me(half: Half) -> Result<PathBuf, String> {
    let own = std::env::current_exe()
        .map_err(|e| format!("cannot locate this executable: {e}"))?;
    let directory = own
        .parent()
        .ok_or_else(|| format!("{} has no directory", own.display()))?;
    beside(directory, half)
}

/// The command that installs both halves, run from `wsb/`. One command for the
/// pair: QuickRust installs several packages of one workspace together, into
/// `$QRUST_HOME/bin` (else `~/.qrust/bin`). Not `--locked`: the workspace's
/// lock file is not committed, so a fresh checkout has none to hold to.
pub const INSTALL_PAIR: &str = "qrust install wsb wsb-daemon --path . --force";

/// The named half, in `directory`, or a refusal that says how to install it.
///
/// Taking the directory rather than reading `current_exe()` is what makes the
/// rule testable: a test can put one half, both or neither in a directory it
/// controls and ask the same function the binaries ask.
pub fn beside(directory: &Path, half: Half) -> Result<PathBuf, String> {
    let candidate = directory.join(half.file_name());
    if candidate.is_file() {
        return Ok(candidate);
    }
    Err(match half {
        Half::Daemon => format!(
            "the daemon is not installed beside wsb: {} does not exist.\n  \
             They are two packages in one workspace, installed together from wsb/:\n      \
             {INSTALL_PAIR}\n  \
             Or run this command with --no-daemon, which does the work in this \
             process and shows it in no workbench.",
            candidate.display()
        ),
        Half::Cli => format!(
            "wsb is not installed beside the daemon: {} does not exist.\n  \
             They are two packages in one workspace, installed together from wsb/:\n      \
             {INSTALL_PAIR}",
            candidate.display()
        ),
    })
}

/// Start a daemon, and hand back the child so the wait can tell "still coming
/// up" from "already dead".
///
/// Also the length of the log before it started: the log is appended to across
/// daemons, and reporting the whole of it would show an operator the reason
/// the LAST one failed.
/// The arguments the daemon is started with, whoever starts it.
///
/// Extracted because there are now two starters — this command line, and a
/// service definition a supervisor holds — and a daemon that a page started
/// behaving differently from one the system started at login would be two
/// daemons wearing one name. The service bakes what this returns; a test
/// holds the two to the same vector.
///
/// The port is RESOLVED here, which is why this can fail: `FirstFreeFrom`
/// probes, and a definition written for a supervisor has to name a number
/// rather than an intention.
pub fn daemon_arguments(workspace_root: &Path, with: &Spawn) -> Result<Vec<String>, String> {
    let mut arguments: Vec<String> = vec![
        "--workspace".to_string(),
        workspace_root.display().to_string(),
        "--port".to_string(),
        with.http_port.resolve(with.listen)?.to_string(),
        "--listen".to_string(),
        with.listen.to_string(),
        "--peer-port".to_string(),
        with.peer_port.to_string(),
    ];
    if let Some(pid) = with.parent_pid {
        arguments.push("--parent-pid".to_string());
        arguments.push(pid.to_string());
    }
    if with.debug {
        arguments.push("--debug".to_string());
    }
    Ok(arguments)
}

fn spawn(
    state_dir: &Path,
    workspace_root: &Path,
    with: &Spawn,
) -> Result<(std::process::Child, u64), String> {
    let executable = daemon_executable()?;
    std::fs::create_dir_all(state_dir)
        .map_err(|e| format!("cannot create {}: {e}", state_dir.display()))?;
    let log = log_path(state_dir);
    let out = std::fs::OpenOptions::new()
        .create(true)
        .append(true)
        .open(&log)
        .map_err(|e| format!("cannot open {}: {e}", log.display()))?;
    let err = out
        .try_clone()
        .map_err(|e| format!("cannot open {}: {e}", log.display()))?;
    let log_was = std::fs::metadata(&log).map(|m| m.len()).unwrap_or(0);

    // The daemon's own arguments, built first, because they may be handed to a
    // wrapper rather than run directly.
    let arguments = daemon_arguments(workspace_root, with)?;

    // A cgroup of its own, where one can be had.
    //
    // Started from a terminal, the daemon lands in that terminal's transient
    // scope — and so does everything it later runs. systemd-oomd watches
    // cgroups: a build that filled memory made that scope the worst offender,
    // and the daemon was killed for what the build used. A long-lived server
    // has no business living inside the scope of whichever window happened to
    // start it.
    let mut argv = vec![executable.display().to_string()];
    argv.extend(arguments.iter().cloned());
    let scoped = crate::ops::scope::wrap(
        &argv,
        &format!("wsb-daemon-{}", crate::ops::scope::workspace_tag(workspace_root)),
        crate::ops::scope::DAEMON_PROPERTIES,
    );

    // Through the one constructor, which also strips the git environment: a
    // daemon started from inside a git hook must not inherit `GIT_DIR`, or it
    // would resolve its workspace — and therefore its store — from whichever
    // repository invoked the hook.
    let mut command = match &scoped {
        Some(wrapped) => {
            let mut command = crate::git_backend::command_without_repo_scope(&wrapped[0]);
            command.args(&wrapped[1..]);
            command
        }
        None => {
            let mut command = crate::git_backend::command_without_repo_scope(&executable);
            command.args(&arguments);
            command
        }
    };
    command
        .stdin(std::process::Stdio::null())
        .stdout(std::process::Stdio::from(out))
        .stderr(std::process::Stdio::from(err));
    // It outlives this command line: its own session, out of the terminal's
    // reach.
    command.detached();
    // And holding nothing of this command line's: see
    // `withhold_standard_handles` for the pipe a daemon held open for ever.
    crate::platform::withhold_standard_handles()
        .map_err(|e| format!("cannot keep this process's output out of the daemon: {e}"))?;

    let child = command
        .spawn()
        .map_err(|e| format!("cannot start {}: {e}", executable.display()))?;
    Ok((child, log_was))
}

/// What the daemon wrote after it was started.
///
/// Read from where the log stood before this one began, so a daemon that could
/// not bind a port shows THAT rather than the last failure of the last daemon
/// to write here.
fn log_since(state_dir: &Path, from: u64) -> Option<String> {
    use std::io::{Read, Seek, SeekFrom};

    let mut file = std::fs::File::open(log_path(state_dir)).ok()?;
    file.seek(SeekFrom::Start(from)).ok()?;
    let mut text = String::new();
    file.read_to_string(&mut text).ok()?;

    // The tail, and only the interesting part: the daemon logs its startup
    // and its migrations before it fails, and an operator reading a refusal
    // wants the last thing that happened.
    let trimmed = text.trim();
    if trimmed.is_empty() {
        return None;
    }
    let tail: Vec<&str> = trimmed.lines().rev().take(12).collect();
    Some(
        tail.into_iter()
            .rev()
            .collect::<Vec<_>>()
            .join("\n      "),
    )
}

fn wait_for_daemon(
    state_dir: &Path,
    workspace_root: &Path,
    mut child: std::process::Child,
    log_was: u64,
    timeout: Duration,
) -> Result<(Client, Info), String> {
    let deadline = Instant::now() + timeout;
    let mut last_error = String::from("the daemon published no rendezvous file");
    while Instant::now() < deadline {
        if let Some(rendezvous) = MachineRendezvous::read(state_dir)? {
            let client = Client::from_rendezvous(&rendezvous)?;
            match client.info() {
                Ok(info) => {
                    require_same_version(&info)?;
                    client.resolve_worktree(workspace_root)?;
                    // The daemon is up and outlives this command, so its exit
                    // status is not this command's to collect. The reaping
                    // thread releases the process entry whenever it does end.
                    std::thread::spawn(move || {
                        let _ = child.wait();
                    });
                    return Ok((client, info));
                }
                Err(error) => last_error = error,
            }
        }

        // A daemon that has already exited is not one to keep waiting for.
        // Waiting out the timeout for a process that died in the first
        // hundred milliseconds is twenty seconds of silence and then a
        // diagnostic that says nothing about why — which is what a port
        // collision looked like from here.
        match child.try_wait() {
            Ok(Some(status)) => {
                return Err(match log_since(state_dir, log_was) {
                    Some(said) => format!(
                        "the daemon stopped immediately ({status}). It said:\n      {said}\n  \
                         its full output is in {}",
                        log_path(state_dir).display()
                    ),
                    None => format!(
                        "the daemon stopped immediately ({status}) without saying why.\n  \
                         its output would be in {}",
                        log_path(state_dir).display()
                    ),
                });
            }
            // Still running: it is starting up.
            Ok(None) => {}
            Err(error) => {
                return Err(format!("cannot tell whether the daemon is running: {error}"))
            }
        }

        std::thread::sleep(START_POLL);
    }

    // Ended, not left to go on starting. A daemon past its deadline that was
    // left running was invisible to the next command — it had published
    // nothing — so that command started a second one, and the first went on
    // to claim the port the second needed (20 Oct 2026). Its file, if it
    // published one in the moment since the last look, goes with it.
    let pid = child.id();
    let ended = match child.kill().and_then(|()| child.wait()) {
        Ok(_) => "it has been stopped".to_string(),
        Err(error) => format!("stopping it failed ({error}); it is pid {pid}"),
    };
    MachineRendezvous::remove_if_published_by(state_dir, pid)?;
    Err(format!(
        "the daemon did not start within {}s, and {ended}: {last_error}\n  \
         its output is in {}",
        timeout.as_secs(),
        log_path(state_dir).display()
    ))
}

/// Refuse a daemon built from a different wsb.
///
/// They share this protocol, the workspace database and its migrations. A
/// daemon from before a migration would answer questions about a schema this
/// wsb has moved past, and the damage would be silent — so the mismatch is
/// reported with the one command that fixes it.
fn require_same_version(info: &Info) -> Result<(), String> {
    let mine = crate::get_version();
    if info.version == mine {
        return Ok(());
    }
    Err(format!(
        "the running daemon is wsb {} and this is wsb {mine}.\n  \
         They share the workspace database and this protocol, so they are not \
         mixed. Stop the old one and this command will start the new one:\n      \
         wsb daemon stop",
        info.version
    ))
}

#[cfg(test)]
mod tests {
    use super::*;

    /// TEST9925: each half of the pair looks for the other BESIDE itself.
    ///
    /// The two are installed together and share a database schema and a
    /// control protocol, so "whichever is on PATH" is the wrong answer in both
    /// directions. This is also the rule the daemon broke when the server left
    /// the wsb binary: the operation runner went on spawning `current_exe()`,
    /// which had silently become the daemon, and every operation started from
    /// a page died with "unknown argument: test" before doing anything.
    #[test]
    fn test9925_the_pair_is_found_beside_itself_never_on_path() {
        let temp = tempfile::tempdir().expect("tempdir");
        let directory = temp.path();

        // Neither installed: each refusal names the missing file and how to
        // install it, rather than reporting the operation as broken.
        let missing_daemon =
            beside(directory, Half::Daemon).expect_err("an absent daemon must be refused");
        assert!(missing_daemon.contains("wsb-daemon"), "{missing_daemon}");
        assert!(missing_daemon.contains(INSTALL_PAIR), "{missing_daemon}");
        let missing_cli = beside(directory, Half::Cli).expect_err("an absent wsb must be refused");
        assert!(missing_cli.contains("wsb is not installed"), "{missing_cli}");
        assert!(missing_cli.contains(INSTALL_PAIR), "{missing_cli}");

        // Installed: the one in THIS directory, whatever is on PATH.
        for half in [Half::Cli, Half::Daemon] {
            std::fs::write(directory.join(half.file_name()), b"#!/bin/sh\n").expect("write");
        }
        let cli = beside(directory, Half::Cli).expect("wsb is here");
        let daemon = beside(directory, Half::Daemon).expect("the daemon is here");
        assert_eq!(cli.parent(), Some(directory));
        assert_eq!(daemon.parent(), Some(directory));
        assert_ne!(cli, daemon, "the two halves must not resolve to one file");

        // A directory holding only one half refuses for the other, rather than
        // handing back the one it has.
        let lonely = tempfile::tempdir().expect("tempdir");
        std::fs::write(lonely.path().join(Half::Cli.file_name()), b"x").expect("write");
        assert!(beside(lonely.path(), Half::Cli).is_ok());
        assert!(beside(lonely.path(), Half::Daemon).is_err());
    }

    fn info(pid: u32, port: u16) -> Info {
        Info {
            version: env!("CARGO_PKG_VERSION").to_string(),
            pid,
            http_address: std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST),
            http_port: port,
            machine_id: "0000000000000000".to_string(),
            machine_name: "test".to_string(),
            peer_address: std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST),
            peer_port: 0,
            peer_refused: None,
            worktrees: Vec::new(),
        }
    }

    fn rendezvous(pid: u32, port: u16) -> MachineRendezvous {
        MachineRendezvous {
            version: env!("CARGO_PKG_VERSION").to_string(),
            pid,
            http_address: std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST),
            http_port: port,
            machine_id: "0000000000000000".to_string(),
            machine_name: "test".to_string(),
            peer_address: std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST),
            peer_port: 0,
            started_at: 0,
        control_token: String::new(),
        }
    }

    /// A listener on loopback, and the port it took.
    fn listening() -> (std::net::TcpListener, u16) {
        let listener = std::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0))
            .expect("a loopback port");
        let port = listener.local_addr().expect("the bound address").port();
        (listener, port)
    }

    /// A process id that is certainly not running: a child's, once reaped.
    fn dead_pid() -> u32 {
        // This test binary, which lists its tests and exits: on every platform.
        let me = std::env::current_exe().expect("this test binary");
        let mut child = crate::git_backend::command_without_repo_scope(&me)
            .arg("--list")
            .stdout(std::process::Stdio::null())
            .spawn()
            .expect("the test binary runs");
        let pid = child.id();
        child.wait().expect("reaped");
        pid
    }

    /// TEST12916: a daemon that has not started by the deadline is stopped,
    /// not left to go on starting.
    ///
    /// Left running, it was invisible to the next command — it had published
    /// nothing — which started a second daemon beside it, and the first then
    /// claimed the port the second needed. The child here never publishes, as
    /// a daemon still installing its watchers does not.
    #[test]
    fn test12916_a_daemon_past_its_start_deadline_is_stopped() {
        let dir = tempfile::tempdir().expect("a scratch directory");
        #[cfg(unix)]
        let starting = crate::git_backend::command_without_repo_scope("sleep").arg("60").spawn();
        #[cfg(windows)]
        let starting = crate::git_backend::command_without_repo_scope("ping")
            .args(["-n", "61", "127.0.0.1"])
            .stdout(std::process::Stdio::null())
            .spawn();
        let child = starting.expect("a process that outlives the deadline");
        let pid = child.id();

        let refused = wait_for_daemon(dir.path(), dir.path(), child, 0, Duration::from_millis(300))
            .expect_err("a daemon that never published has not started");
        assert!(refused.contains("has been stopped"), "{refused}");
        assert!(
            !crate::ops::process::is_alive(pid),
            "the daemon that missed its deadline is still running as pid {pid}"
        );
    }

    /// TEST12584: a daemon that does not answer is not taken for gone while it
    /// is still serving; one that is gone is.
    ///
    /// Under heavy load the live daemon answered too late. Every command took
    /// it for gone, removed its file and started another, and the last to exit
    /// left a healthy daemon that no file named. Serving means its process is
    /// alive AND its port held; then it is waited for and refused as busy.
    #[test]
    fn test12584_a_busy_daemon_is_not_taken_for_gone() {
        // A port that accepts and never answers, held by this live process.
        let (listener, port) = listening();
        let busy = rendezvous(std::process::id(), port);
        assert!(still_serving(&busy), "a live process holding its port is serving");
        let client = Client::from_rendezvous(&busy).expect("a client");
        let refused = answer_of(&client, &busy, Duration::from_secs(1))
            .expect_err("a serving daemon that does not answer is refused as busy, not replaced");
        assert!(refused.contains("busy, not gone"), "{refused}");

        // Its process gone: stale, whatever holds the port.
        let dead = rendezvous(dead_pid(), port);
        assert!(!still_serving(&dead), "a dead process is not serving");
        // Its port free: stale, whatever has its pid now.
        drop(listener);
        let freed = rendezvous(std::process::id(), port);
        assert!(!still_serving(&freed), "nothing holds the port");
        let client = Client::from_rendezvous(&freed).expect("a client");
        assert!(
            answer_of(&client, &freed, Duration::from_secs(60)).expect("gone is an answer").is_none(),
            "a daemon that is gone is reported gone, at once"
        );
    }

    /// TEST9580: a daemon still holding its port has not stopped.
    ///
    /// `shutdown` is ANSWERED before the process ends, so "it acknowledged" and
    /// "it is gone" are different moments. A restart that started the replacement
    /// at the first moment started it against a port the old daemon still held —
    /// the new one failed to bind, the rendezvous file still named the old one,
    /// and the command talked to a process on its way out. That is what `resolve`
    /// timing out looked like.
    #[test]
    fn test9580_a_daemon_still_on_its_port_has_not_stopped() {
        let dir = tempfile::tempdir().expect("a scratch directory");
        let (_listener, port) = listening();

        // Unpublished already — the daemon removes its rendezvous file just before
        // it exits — and still holding the port.
        assert!(
            !is_stopped(dir.path(), &info(4242, port)).expect("the state is readable"),
            "a daemon that still holds its port has not stopped, whatever the \
             rendezvous file says"
        );
    }

    /// TEST9581: a daemon whose rendezvous file still names it has not stopped.
    #[test]
    fn test9581_a_daemon_that_still_publishes_itself_has_not_stopped() {
        let dir = tempfile::tempdir().expect("a scratch directory");
        let (listener, port) = listening();
        rendezvous(4242, port)
            .write(dir.path())
            .expect("the rendezvous file is written");

        assert!(
            !is_stopped(dir.path(), &info(4242, port)).expect("the state is readable"),
            "a daemon that still publishes itself has not stopped"
        );

        // Both halves go: the port is released and the file is taken with it.
        drop(listener);
        MachineRendezvous::remove_if_published_by(dir.path(), 4242).expect("the rendezvous file is removed");
        assert!(
            is_stopped(dir.path(), &info(4242, port)).expect("the state is readable"),
            "once the port is free and nothing publishes this daemon, it is gone"
        );
    }

    /// TEST9582: a rendezvous file naming a DIFFERENT daemon is not this one.
    ///
    /// A replacement that published itself before the caller looked must not read
    /// as "the old daemon is still here", or a stop would wait out its timeout
    /// against a healthy daemon.
    #[test]
    fn test9582_another_daemons_rendezvous_is_not_this_one() {
        let dir = tempfile::tempdir().expect("a scratch directory");
        let (listener, port) = listening();
        let replacement = listener.local_addr().expect("the bound address").port() + 1;
        rendezvous(9999, replacement)
            .write(dir.path())
            .expect("the rendezvous file is written");
        drop(listener);

        assert!(
            is_stopped(dir.path(), &info(4242, port)).expect("the state is readable"),
            "the daemon that was stopped is gone; the file names its successor"
        );
    }
}