OXYGENHUB

A Windows runner updates itself 494a1b9a8ac2

PE check; flow.exe renamed aside and flow.new.exe into place with
MoveFileExW (retrying while a scanner holds the file); a copy of flow
renames its own running file (flow __rename); the runner is told by an
update.request file and exits 75 having left, so a Task Scheduler task
restarting on failure starts the new build. Tried in the Windows VM:
install, leave, exit 75, restart, proven. Spec 11.5.3.

refs #83, #85
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-26 03:20 UTC
Signature
◈ sealed by Andy McCall
Hash
494a1b9a8ac268416c2fb9b53cf0277143685bdcacd64a00643abcec506b549c
Depends on
28cc603f737d 3007d8f011fa 3554afefb6aa 5cf1e8aefbb6 6f66e397539c 731413202557 85275e1ef3b7 a083cc802892 c3db750ed96e cedd12342afa e0918b887850 ea56c56eaf90
Depended on by
8aa45fa6ece5 f4dfd742b30d 4dd392d16454
In bookmarks
main
In tags
v0.4.0 v0.5.0 v0.5.1 v0.5.2 v0.5.3
Edits
38 atoms, 0 tree operations, 8 files

Browse the tree at this change

modified CHANGELOG.md +1 −0
…
3 unchanged lines
4 4
5 5
## Unreleased
6 6
7 +
- **A Windows runner updates itself (#83, #85):** `flow runner update` on Windows checks a PE build, renames `flow.exe` to `flow.prev.exe` and `flow.new.exe` into its place with `MoveFileExW` (retrying while a virus scanner holds a new file; a running program cannot rename its own file, so a copy of it, `flow __rename`, does), tells the running runner through an `update.request` file in its home (no SIGUSR1 there), and the runner leaves and exits 75 so a Task Scheduler task set to restart on failure starts the new build, which proves itself by reaching the hub. Tried in the Windows VM: install, leave, exit 75, restart, proven. A rollback runs the restored build and ends with it. Spec §11.5.3.
7 8
- **`flow` builds for Windows (#83, step 2 begun):** `cargo build --target x86_64-pc-windows-gnu` gives a `flow.exe` that offers the WASM tier: the syscall tier, the guest helpers and the egress proxy are Linux's only (a Windows build says the syscall tier "runs only on Linux"), cache pruning by reads stays on Linux, and file modes, links and sizes go through a small `portable` module (modes are not kept on Windows). Its claims report free disk (`GetDiskFreeSpaceExW`); load and kernel are not measured there yet, and a Windows runner does not update itself. `dist` builds `flow-windows-x86_64.exe` too. Nothing changes on Linux or Android. Tried in the Windows VM on Earth: `flow.exe tiers` offers wasm, a local `flow run` of a 120-step WASM job passes, and registered as runner `windows-1` (label `windows`) it ran andymccall/windows-runner-demo's job from the hub (run #1). Helpers start from the running binary's path on Windows (`/proc/self/exe` on Linux and Android).
8 9
- **The syscall tier's loopback reaches TCP (#73):** with `network "loopback"`, a job may open IPv4 and IPv6 sockets, which in its own network namespace reach only its `lo`, so a server it starts on 127.0.0.1 answers it (a conformance suite over an emulator's HTTP control port, which skipped every test on narvi-1). With `network "isolated"` sockets other than `AF_UNIX` are still refused.
9 10
- **Runner builds in releases (oxygenhub#322):** a `dist` job (`scripts/dist.sh`, on a change to `Cargo.toml` or the script) builds `flow` stripped for Linux x86_64, aarch64 and riscv64 (glibc 2.28 and later) as `flow-linux-ARCH`, named by the architecture a runner reports, with `SHA256SUMS`: the files a release carries for runners that update themselves.
…
100 unchanged lines
modified README.md +4 −2
…
60 unchanged lines
61 61
for a runner of your own repositories). A pipeline sends it work with a
62 62
`runtime "wasm"` sandbox and `runs-on "windows"`. To keep it running, a
63 63
Task Scheduler task at start-up with that command (restarting on failure)
64 -
does. What a Windows runner does not do yet: report load and kernel, update
65 -
itself, or drain when Windows stops it (oxygenflow#83).
64 +
does; set it to restart on failure, as a runner that leaves for a new build
65 +
exits 75 (`flow runner update`, spec §11.5.3). What a Windows runner does
66 +
not do yet: report load and kernel, or drain when Windows stops it
67 +
(oxygenflow#83).
66 68
67 69
## Commands
68 70
…
71 unchanged lines
modified crates/flow/Cargo.toml +2 −1
…
33 unchanged lines
34 34
wasmtime = { version = "=49.0.4", default-features = false, features = ["cranelift", "runtime", "std", "parallel-compilation"] }
35 35
wasmtime-wasi = { version = "=49.0.4", default-features = false, features = ["p1"] }
36 36
37 -
# A Windows runner's free disk (oxygenflow#83); already in the tree through wasmtime.
37 +
# A Windows runner's free disk and renaming a running flow.exe (oxygenflow#83);
38 +
# already in the tree through wasmtime.
38 39
[target.'cfg(windows)'.dependencies]
39 40
windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem"] }
40 41
…
4 unchanged lines
modified crates/flow/src/main.rs +15 −1
…
127 unchanged lines
128 128
    /// Check that this machine can run the syscall tier
129 129
    #[command(name = "__sandbox-probe", hide = true)]
130 130
    SandboxProbe,
131 +
    /// Rename a program that cannot rename its own file (Windows; #83)
132 +
    #[command(name = "__rename", hide = true)]
133 +
    Rename { from: PathBuf, to: PathBuf },
131 134
    /// The WASM tier's helper, started by the runner for each job
132 135
    #[command(name = "__wasm", hide = true)]
133 136
    Wasm { spec: PathBuf },
…
46 unchanged lines
180 183
    match cli.cmd {
181 184
        Cmd::Sandbox { spec } => std::process::exit(syscall::sandbox_main(&spec)),
182 185
        Cmd::SandboxProbe => std::process::exit(syscall::probe_main()),
186 +
        Cmd::Rename { from, to } => {
187 +
            flow::portable::rename(&from, &to).with_context(|| format!("renaming {} to {}", from.display(), to.display()))?;
188 +
            Ok(())
189 +
        }
183 190
        Cmd::Wasm { spec } => std::process::exit(wasm::wasm_main(&spec)),
184 191
        Cmd::Check { file } => check(&file.unwrap_or_else(|| PathBuf::from(PIPELINE_PATH))),
185 192
        Cmd::Hash { file } => {
…
129 unchanged lines
315 322
                    let err = std::process::Command::new(&p.exe).args(std::env::args_os().skip(1)).exec();
316 323
                    bail!("starting flow {} again after the rollback: {err}", p.from);
317 324
                }
325 +
                // Windows has no exec: run the restored build and end with it.
318 326
                #[cfg(not(unix))]
319 -
                bail!("rolled back to flow {}; start the runner again", p.from);
327 +
                {
328 +
                    let status = std::process::Command::new(&p.exe)
329 +
                        .args(std::env::args_os().skip(1))
330 +
                        .status()
331 +
                        .with_context(|| format!("starting flow {} again after the rollback", p.from))?;
332 +
                    std::process::exit(status.code().unwrap_or(1));
333 +
                }
320 334
            }
321 335
            // The runner's oxy state (identity, user config) stays in its home.
322 336
            if std::env::var_os("OXY_HOME").is_none() {
…
295 unchanged lines
modified crates/flow/src/portable.rs +32 −0
…
103 unchanged lines
104 104
        std::os::windows::fs::symlink_file(target, link)
105 105
    }
106 106
}
107 +
108 +
/// Rename `from` to `to`, which must not exist. On Windows through
109 +
/// `MoveFileExW`, which renames a program that is running, trying again
110 +
/// while another program (a virus scanner) holds the file.
111 +
pub fn rename(from: &Path, to: &Path) -> io::Result<()> {
112 +
    #[cfg(windows)]
113 +
    {
114 +
        use std::os::windows::ffi::OsStrExt;
115 +
        let wide = |p: &Path| p.as_os_str().encode_wide().chain(Some(0)).collect::<Vec<u16>>();
116 +
        let (f, t) = (wide(from), wide(to));
117 +
        // A virus scanner holds a new program for a while after it is
118 +
        // written (a sharing violation, 32, or access denied, 5): try again
119 +
        // for half a minute.
120 +
        let mut tries = 0;
121 +
        loop {
122 +
            // SAFETY: both are NUL-terminated wide paths.
123 +
            if unsafe { windows_sys::Win32::Storage::FileSystem::MoveFileExW(f.as_ptr(), t.as_ptr(), 0) } != 0 {
124 +
                return Ok(());
125 +
            }
126 +
            let e = io::Error::last_os_error();
127 +
            tries += 1;
128 +
            if tries >= 30 || !matches!(e.raw_os_error(), Some(32) | Some(5)) {
129 +
                return Err(e);
130 +
            }
131 +
            std::thread::sleep(std::time::Duration::from_secs(1));
132 +
        }
133 +
    }
134 +
    #[cfg(not(windows))]
135 +
    {
136 +
        std::fs::rename(from, to)
137 +
    }
138 +
}
modified crates/flow/src/runner.rs +12 −1
…
682 unchanged lines
683 683
    }
684 684
    let served = serve_until_drained(home);
685 685
    crate::update::forget_runner(&home.root);
686 +
    // Left for a new build: on Windows, end as a failure, so a Task
687 +
    // Scheduler task set to restart on failure starts it (systemd's
688 +
    // Restart=always starts it after a clean exit).
689 +
    if cfg!(windows) && served.is_ok() && LEFT_FOR_UPDATE.load(std::sync::atomic::Ordering::SeqCst) {
690 +
        eprintln!("flow runner: left for the new build; exiting 75 so it is started");
691 +
        std::process::exit(75);
692 +
    }
686 693
    served
687 694
}
695 +
696 +
/// Set once the runner leaves for a new build.
697 +
static LEFT_FOR_UPDATE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
688 698
689 699
/// Set by SIGUSR1: a new build is in place; leave for it.
690 700
static UPDATE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
…
124 unchanged lines
815 825
        let mut update_asked: Option<Instant> = None;
816 826
        let mut update_said = String::new();
817 827
        loop {
818 -
            if UPDATE.swap(false, std::sync::atomic::Ordering::SeqCst) {
828 +
            if UPDATE.swap(false, std::sync::atomic::Ordering::SeqCst) || crate::update::requested(&home.root) {
819 829
                let p = crate::update::pending(&home.root);
820 830
                let notice = Duration::from_secs(p.as_ref().map_or(0, |p| p.notice_secs));
821 831
                let to = p.map_or_else(|| "a new build".to_string(), |p| format!("flow {}", p.to));
822 832
                eprintln!("flow runner {name}: updating to {to}: leaving within {} s", notice.as_secs());
833 +
                LEFT_FOR_UPDATE.store(true, std::sync::atomic::Ordering::SeqCst);
823 834
                leave_within(&format!("updating to {to}"), notice);
824 835
            }
825 836
            // Draining (oxygenflow#50): told to stop, claim nothing more and
…
1421 unchanged lines
modified crates/flow/src/update.rs +145 −11
…
19 unchanged lines
20 20
const LOG: &str = "updates.log";
21 21
/// In the runner's home: the running runner's process id.
22 22
const PID: &str = "runner.pid";
23 +
/// In the runner's home: a new build is in place, leave for it. How a
24 +
/// runner is told on Windows, which has no SIGUSR1.
25 +
const REQUEST: &str = "update.request";
23 26
/// Starts of an unproven build after which the previous one comes back.
24 27
pub const MAX_STARTS: u32 = 3;
25 28
/// How long a new build has to reach the hub.
…
35 unchanged lines
61 64
    parts(a) < parts(b)
62 65
}
63 66
67 +
/// `exe`'s sibling named `stem`, with `exe`'s extension (`flow.prev.exe`
68 +
/// beside `flow.exe` on Windows, `flow.prev` beside `flow` elsewhere).
69 +
pub fn beside(exe: &Path, stem: &str) -> PathBuf {
70 +
    match exe.extension() {
71 +
        Some(e) => exe.with_file_name(format!("{stem}.{}", e.to_string_lossy())),
72 +
        None => exe.with_file_name(stem),
73 +
    }
74 +
}
75 +
76 +
/// The PE machine number of this build's architecture (Windows).
77 +
fn pe_machine_here() -> Option<u16> {
78 +
    Some(match std::env::consts::ARCH {
79 +
        "x86_64" => 0x8664,
80 +
        "aarch64" => 0xAA64,
81 +
        _ => return None,
82 +
    })
83 +
}
84 +
85 +
/// Refuse a file that is not a PE executable for this machine (Windows).
86 +
pub fn check_pe(bytes: &[u8]) -> Result<()> {
87 +
    if bytes.len() < 0x40 || &bytes[..2] != b"MZ" {
88 +
        bail!("not a Windows executable");
89 +
    }
90 +
    let at = u32::from_le_bytes([bytes[0x3c], bytes[0x3d], bytes[0x3e], bytes[0x3f]]) as usize;
91 +
    if bytes.len() < at + 6 || &bytes[at..at + 4] != b"PE\0\0" {
92 +
        bail!("not a Windows executable (no PE header in its first {} bytes)", bytes.len());
93 +
    }
94 +
    let machine = u16::from_le_bytes([bytes[at + 4], bytes[at + 5]]);
95 +
    match pe_machine_here() {
96 +
        Some(m) if m == machine => Ok(()),
97 +
        Some(_) => bail!("a build for another machine (PE machine {machine:#x}); this runner is {}", std::env::consts::ARCH),
98 +
        None => bail!("updates are not supported on {}", std::env::consts::ARCH),
99 +
    }
100 +
}
101 +
64 102
/// The ELF machine number of this build's architecture.
65 103
fn machine_here() -> Option<u16> {
66 104
    Some(match std::env::consts::ARCH {
…
54 unchanged lines
121 159
/// starts and names its version, and it finds the tiers this home offers.
122 160
/// Returns its version.
123 161
pub fn check(file: &Path, home: &Path) -> Result<String> {
124 -
    let mut head = vec![0u8; 64];
162 +
    let mut head = vec![0u8; 4096];
125 163
    let n = std::io::Read::read(&mut std::fs::File::open(file).with_context(|| format!("opening {}", file.display()))?, &mut head)?;
126 -
    check_target(&head[..n])?;
164 +
    if cfg!(windows) {
165 +
        check_pe(&head[..n])?;
166 +
    } else {
167 +
        check_target(&head[..n])?;
168 +
    }
127 169
    let limit = Duration::from_secs(30);
128 170
    let line = run(file, &["--version"], limit)?;
129 171
    let version = parse_version(&line).with_context(|| format!("{} does not say it is flow: {:?}", file.display(), line.trim()))?;
…
27 unchanged lines
157 199
    std::fs::write(&tmp, serde_json::to_vec_pretty(p)?)?;
158 200
    std::fs::rename(&tmp, home.join(PENDING))?;
159 201
    Ok(())
202 +
}
203 +
204 +
/// Whether `p` is the program running now.
205 +
fn is_self(p: &Path) -> bool {
206 +
    let canon = |p: &Path| std::fs::canonicalize(p).ok();
207 +
    std::env::current_exe().ok().and_then(|me| canon(&me)).is_some_and(|me| Some(me) == canon(p))
208 +
}
209 +
210 +
/// Rename program `from` to `to`. On Windows a running program cannot
211 +
/// rename its own file (other programs can), so a copy of it, run as
212 +
/// `flow __rename`, does it then.
213 +
fn rename_exe(from: &Path, to: &Path) -> Result<()> {
214 +
    if cfg!(windows) && is_self(from) {
215 +
        let helper = beside(from, "flow.rename");
216 +
        std::fs::copy(from, &helper).with_context(|| format!("copying {} to rename it", from.display()))?;
217 +
        let out = std::process::Command::new(&helper).arg("__rename").arg(from).arg(to).output();
218 +
        let _ = std::fs::remove_file(&helper);
219 +
        let out = out.with_context(|| format!("starting {}", helper.display()))?;
220 +
        if !out.status.success() {
221 +
            bail!("{}", String::from_utf8_lossy(&out.stderr).trim());
222 +
        }
223 +
        return Ok(());
224 +
    }
225 +
    Ok(crate::portable::rename(from, to)?)
160 226
}
161 227
162 228
/// Put `staged` in place of `exe`, keeping `exe` as [`PREV`] beside it.
163 229
/// `exe` is never missing: it is copied aside, then replaced in one rename.
230 +
/// On Windows, where a running program cannot be replaced but can be
231 +
/// renamed, `exe` is renamed aside and `staged` into its place.
164 232
pub fn swap(exe: &Path, staged: &Path) -> Result<()> {
165 -
    let prev = exe.with_file_name(PREV);
233 +
    let prev = beside(exe, PREV);
234 +
    if cfg!(windows) {
235 +
        let _ = std::fs::remove_file(&prev);
236 +
        rename_exe(exe, &prev).with_context(|| format!("keeping {} as {}", exe.display(), prev.display()))?;
237 +
        if let Err(e) = crate::portable::rename(staged, exe) {
238 +
            let _ = crate::portable::rename(&prev, exe);
239 +
            return Err(e).with_context(|| format!("putting {} in place of {}", staged.display(), exe.display()));
240 +
        }
241 +
        return Ok(());
242 +
    }
166 243
    let _ = std::fs::remove_file(&prev);
167 244
    if std::fs::hard_link(exe, &prev).is_err() {
168 245
        std::fs::copy(exe, &prev).with_context(|| format!("keeping {} as {}", exe.display(), prev.display()))?;
…
4 unchanged lines
173 250
174 251
/// Put [`PREV`] back in place of `exe`.
175 252
pub fn restore(exe: &Path) -> Result<()> {
176 -
    let prev = exe.with_file_name(PREV);
253 +
    let prev = beside(exe, PREV);
177 254
    if !prev.is_file() {
178 255
        bail!("{} is not there to go back to", prev.display());
256 +
    }
257 +
    if cfg!(windows) {
258 +
        // The failed build may be the one running: rename it aside first.
259 +
        let failed = beside(exe, "flow.failed");
260 +
        let _ = std::fs::remove_file(&failed);
261 +
        rename_exe(exe, &failed).with_context(|| format!("moving {} aside", exe.display()))?;
262 +
        return crate::portable::rename(&prev, exe).with_context(|| format!("putting {} back", prev.display()));
179 263
    }
180 264
    std::fs::rename(&prev, exe).with_context(|| format!("putting {} back", prev.display()))?;
181 265
    Ok(())
…
3 unchanged lines
185 269
/// tell the running runner (if any) to leave within `notice`, finishing
186 270
/// what fits and handing back the rest, so its unit starts the new build.
187 271
pub fn install(home: &crate::home::Home, exe: &Path, file: &Path, notice: Duration, dry_run: bool, allow_older: bool) -> Result<Installed> {
188 -
    if !cfg!(unix) {
189 -
        bail!("runners update themselves on Linux only, so far");
190 -
    }
191 272
    if home.config.runner.as_ref().and_then(|r| r.update.as_ref()).is_some_and(|u| !u.allow) {
192 273
        bail!("flow.toml says [runner.update] allow = false: this runner takes no updates");
193 274
    }
…
1 unchanged lines
195 276
        bail!("the update from {} to {} is not proven yet; wait for it (or for its rollback)", p.from, p.to);
196 277
    }
197 278
    let from = env!("CARGO_PKG_VERSION").to_string();
198 -
    let staged = exe.with_file_name(NEW);
279 +
    let staged = beside(exe, NEW);
199 280
    let _ = std::fs::remove_file(&staged);
200 281
    std::fs::copy(file, &staged).with_context(|| format!("staging {} as {}", file.display(), staged.display()))?;
201 282
    crate::portable::set_mode(&staged, 0o755)?;
…
13 unchanged lines
215 296
    if dry_run {
216 297
        let _ = std::fs::remove_file(&staged);
217 298
        return Ok(Installed { from, to, exe: exe.to_path_buf(), told: None });
299 +
    }
300 +
    if let Err(e) = swap(exe, &staged) {
301 +
        let _ = std::fs::remove_file(&staged);
302 +
        return Err(e);
218 303
    }
219 -
    swap(exe, &staged)?;
220 304
    let p =
221 305
        Pending { from: from.clone(), to: to.clone(), exe: exe.to_path_buf(), at_ms: now_ms(), notice_secs: notice.as_secs(), starts: 0 };
222 306
    save(&home.root, &p)?;
223 307
    log(&home.root, "installed", &from, &to, "flow runner update --from");
224 -
    let told = running(&home.root).filter(|pid| tell(*pid));
308 +
    let told = running(&home.root).filter(|pid| cfg!(not(unix)) || tell(*pid));
225 309
    Ok(Installed { from, to, exe: exe.to_path_buf(), told })
226 310
}
227 311
…
3 unchanged lines
231 315
    unsafe { libc::kill(pid as libc::pid_t, libc::SIGUSR1) == 0 }
232 316
}
233 317
318 +
/// On Windows: a request in the runner's home, which its loop reads.
234 319
#[cfg(not(unix))]
235 320
fn tell(_pid: u32) -> bool {
236 321
    false
237 322
}
238 323
324 +
/// Whether the runner has been asked, by file, to leave for a new build
325 +
/// (and forget the asking).
326 +
pub fn requested(home: &Path) -> bool {
327 +
    std::fs::remove_file(home.join(REQUEST)).is_ok()
328 +
}
329 +
239 330
/// The running runner's process id, from its pid file, if that process is
240 331
/// still a `flow runner`: a stale file's pid may be another program's now,
241 332
/// and SIGUSR1 would end it. Builds before #85 write no pid file (and do
242 333
/// not listen for SIGUSR1), so they are never told.
334 +
#[cfg(unix)]
243 335
fn running(home: &Path) -> Option<u32> {
244 336
    let pid: u32 = std::fs::read_to_string(home.join(PID)).ok()?.trim().parse().ok()?;
245 337
    let cmdline = std::fs::read(format!("/proc/{pid}/cmdline")).ok()?;
246 338
    is_runner(&cmdline).then_some(pid)
339 +
}
340 +
341 +
/// On Windows: a runner of this home has a pid file, and is asked by a
342 +
/// request file it reads (a stale one is cleared when a runner starts).
343 +
#[cfg(not(unix))]
344 +
fn running(home: &Path) -> Option<u32> {
345 +
    let pid: u32 = std::fs::read_to_string(home.join(PID)).ok()?.trim().parse().ok()?;
346 +
    std::fs::write(home.join(REQUEST), pid.to_string()).ok()?;
347 +
    Some(pid)
247 348
}
248 349
249 350
/// Whether a NUL-separated command line is `flow … runner` with no subcommand.
351 +
#[cfg_attr(not(unix), allow(dead_code))]
250 352
fn is_runner(cmdline: &[u8]) -> bool {
251 353
    let args: Vec<&[u8]> = cmdline.split(|b| *b == 0).filter(|a| !a.is_empty()).collect();
252 354
    let Some((argv0, rest)) = args.split_first() else { return false };
…
3 unchanged lines
256 358
257 359
/// Note this process as the home's runner, for `flow runner update` to tell.
258 360
pub fn note_runner(home: &Path) {
361 +
    let _ = std::fs::remove_file(home.join(REQUEST));
259 362
    let _ = std::fs::write(home.join(PID), std::process::id().to_string());
260 363
}
261 364
…
49 unchanged lines
311 414
        let _ = std::fs::remove_file(home.join(PENDING));
312 415
        log(home, "proven", &p.from, &p.to, "reached the hub");
313 416
        note(home, &format!("runs flow {} (was {}); proven", p.to, p.from));
314 -
        eprintln!("flow runner: flow {} proven (it reached the hub); flow {} stays as {PREV}", p.to, p.from);
417 +
        eprintln!("flow runner: flow {} proven (it reached the hub); flow {} stays as {}", p.to, p.from, beside(&p.exe, PREV).display());
315 418
    }
316 419
}
317 420
…
57 unchanged lines
375 478
        assert!(pending(&home).is_none());
376 479
        let log = std::fs::read_to_string(home.join(LOG)).unwrap();
377 480
        assert!(log.contains("rolled back") && log.contains("0.3.2"), "{log}");
481 +
    }
482 +
483 +
    #[test]
484 +
    fn a_windows_build_is_checked_as_pe_and_named_beside_with_its_extension() {
485 +
        let mut pe = vec![0u8; 0x100];
486 +
        pe[..2].copy_from_slice(b"MZ");
487 +
        pe[0x3c..0x40].copy_from_slice(&0x80u32.to_le_bytes());
488 +
        pe[0x80..0x84].copy_from_slice(b"PE\0\0");
489 +
        pe[0x84..0x86].copy_from_slice(&0x8664u16.to_le_bytes());
490 +
        if std::env::consts::ARCH == "x86_64" {
491 +
            check_pe(&pe).unwrap();
492 +
        }
493 +
        pe[0x84..0x86].copy_from_slice(&0x01c4u16.to_le_bytes());
494 +
        assert!(check_pe(&pe).is_err(), "another machine");
495 +
        assert!(check_pe(b"\x7fELF and more bytes than enough for a header, really, quite a few more")
496 +
            .unwrap_err()
497 +
            .to_string()
498 +
            .contains("not a Windows"));
499 +
        assert_eq!(beside(Path::new("C:/flow/flow.exe"), PREV), Path::new("C:/flow/flow.prev.exe"));
500 +
        assert_eq!(beside(Path::new("/usr/local/bin/flow"), NEW), Path::new("/usr/local/bin/flow.new"));
501 +
    }
502 +
503 +
    #[test]
504 +
    fn a_request_file_asks_once() {
505 +
        let dir = tempfile::tempdir().unwrap();
506 +
        assert!(!requested(dir.path()));
507 +
        std::fs::write(dir.path().join(REQUEST), "1").unwrap();
508 +
        assert!(requested(dir.path()) && !requested(dir.path()), "read once");
509 +
        std::fs::write(dir.path().join(REQUEST), "1").unwrap();
510 +
        note_runner(dir.path());
511 +
        assert!(!requested(dir.path()), "a starting runner clears a stale request");
378 512
    }
379 513
380 514
    #[test]
…
24 unchanged lines
modified docs/spec.md +9 −0
…
882 unchanged lines
883 883
   on the fourth start without that answer, or after ten minutes running
884 884
   without it, `flow.prev` is put back (and on a start, run in its place).
885 885
   `updates.log` in the home records every install, proof and rollback.
886 +
6. **On Windows** (oxygenflow#83) the same steps run with Windows' means:
887 +
   the build is checked as a PE executable for this machine; `flow.exe`
888 +
   is renamed to `flow.prev.exe` and `flow.new.exe` into its place (a
889 +
   running program can be renamed but not replaced, and cannot rename
890 +
   its own file, so a copy of it, `flow __rename`, does that); the runner
891 +
   is told by an `update.request` file in its home, which it reads in its
892 +
   loop, there being no SIGUSR1; and having left, it exits 75, so a Task
893 +
   Scheduler task set to restart on failure starts the new build. A
894 +
   rollback runs the restored build in its place and ends with it.
886 895
887 896
### 11.6 Conformance
888 897
…
11 unchanged lines