~james/remy

fix(cli): publish binary and vector output atomically

commit
d3257a26ac346abdf0585b20fadc11e2ca5812bd browse tree
parent
31d281ada88d2822fbbece49ea5e511b0ad3ada3
author
james <j@cpl0.zip>
signature
Verified ~james signed with a key registered by ~james
date
2026-10-24 11:00 UTC

2 files changed, +59 −4

cli/driver.ML modified

@@ -14,12 +14,44 @@ fun read path =
1414 if size input > 65536 then raise Fail "source exceeds 64 KiB" else chars input
1515 end;
1616
17-fun write path bytes =
17+fun write path data =
1818 let
19- val stream = BinIO.openOut path
19+ val bytes = Byte.stringToBytes (text data)
20+ val pid = SysWord.toString (Posix.Process.pidToWord (Posix.ProcEnv.getpid ()))
21+ val prefix = OS.Path.concat (OS.Path.dir path, "." ^ OS.Path.file path ^ "." ^ pid ^ ".")
22+ val defaultMode = Posix.FileSys.S.flags [Posix.FileSys.S.irusr, Posix.FileSys.S.iwusr,
23+ Posix.FileSys.S.irgrp, Posix.FileSys.S.iwgrp, Posix.FileSys.S.iroth, Posix.FileSys.S.iwoth]
24+ val mode = Posix.FileSys.S.intersect [Posix.FileSys.ST.mode (Posix.FileSys.stat path),
25+ Posix.FileSys.S.flags [Posix.FileSys.S.irwxu, Posix.FileSys.S.irwxg, Posix.FileSys.S.irwxo]]
26+ handle exn as OS.SysErr (_, SOME error) =>
27+ if error = Posix.Error.noent then defaultMode else raise exn
28+ fun reserve n =
29+ if n = 128 then raise Fail "cannot reserve output file"
30+ else
31+ let
32+ val temporary = prefix ^ Int.toString n
33+ in
34+ (temporary, Posix.FileSys.createf
35+ (temporary, Posix.FileSys.O_WRONLY, Posix.FileSys.O.excl, mode))
36+ end handle exn as OS.SysErr (_, SOME error) =>
37+ if error = Posix.Error.exist then reserve (n + 1) else raise exn
38+ val (temporary, fd) = reserve 0
39+ val closed = ref false
40+ fun close () = (Posix.IO.close fd; closed := true)
41+ fun discard () =
42+ ((if !closed then () else close ()) handle _ => ();
43+ OS.FileSys.remove temporary handle _ => ())
44+ fun loop offset =
45+ if offset = Word8Vector.length bytes then ()
46+ else
47+ let
48+ val count = Posix.IO.writeVec (fd, Word8VectorSlice.slice (bytes, offset, NONE))
49+ in
50+ if count = 0 then raise Fail "output write made no progress" else loop (offset + count)
51+ end
2052 in
21- (BinIO.output (stream, Byte.stringToBytes (text bytes)); BinIO.closeOut stream)
22- handle exn => ((BinIO.closeOut stream handle _ => ()); raise exn)
53+ (loop 0; close (); OS.FileSys.rename {old = temporary, new = path})
54+ handle exn => (discard (); raise exn)
2355 end;
2456
2557 fun output (ok, message) =

tests/cli.py modified

@@ -1,4 +1,7 @@
11 from pathlib import Path
2+import os
3+import resource
4+import signal
25 import subprocess
36 import tempfile
47
@@ -63,6 +66,26 @@ def main():
6366 invoke('check',binary,ok=False)
6467 binary.write_bytes(bytes([72,199,192,42,0,0,0,195]))
6568 invoke('check',binary)
69+ source.write_text('(+ x y)')
70+ binary.write_bytes(b'preserve')
71+ binary.chmod(0o600)
72+ invoke(source,binary,preexec_fn=lambda: os.umask(0))
73+ assert binary.stat().st_mode & 0o777 == 0o600
74+ def restricted():
75+ signal.signal(signal.SIGXFSZ,signal.SIG_IGN)
76+ resource.setrlimit(resource.RLIMIT_FSIZE,(1,1))
77+ for args in [(source,binary),('vectors',source,binary)]:
78+ binary.write_bytes(b'preserve')
79+ invoke(*args,ok=False,preexec_fn=restricted)
80+ assert binary.read_bytes() == b'preserve'
81+ assert not list(binary.parent.glob('.binary.*'))
82+ target = Path(directory) / 'folder'
83+ target.mkdir()
84+ invoke(source,target,ok=False)
85+ assert not list(target.parent.glob('.folder.*'))
86+ source.write_text('(+ x y)')
87+ invoke(source,source)
88+ assert source.read_bytes().endswith(bytes([195]))
6689 source.write_text('é')
6790 assert 'ASCII' in invoke(source,binary,ok=False)
6891 for text in ['0x','0x10000000000000000','0xfg','0xffextra','-0x1']: