import argparse
import os
from pathlib import Path
import secrets
import socket
import struct
import subprocess
import sys
import tempfile
import time
ROOT = Path(__file__).resolve().parents[1]
MAGIC = b'RMA1'
def snapshot(path):
with path.open('rb') as stream:
before = os.fstat(stream.fileno())
data = stream.read(65537)
after = path.stat()
if (before.st_ino, before.st_size, before.st_mtime_ns) != (after.st_ino, after.st_size, after.st_mtime_ns):
return None
if len(data) > 65536:
raise ValueError('source exceeds 64 KiB')
return data
def compile_packet(compiler, source, revision, directory):
path, packet = directory / 'source', directory / 'packet'
path.write_bytes(source)
with subprocess.Popen([compiler, 'package', str(revision), path, packet],
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) as process:
try:
_, error = process.communicate(timeout=30)
except BaseException:
process.kill()
process.communicate()
raise
if process.returncode:
raise ValueError(error.strip())
return packet.read_bytes()
def watch(source, endpoint, compiler, interval):
previous = None
packet = None
connection = None
flight = None
completed = None
sent = 0.0
revision = secrets.randbelow((1 << 32) - 1) + 1
replies = bytearray()
next_connection = 0.0
source_error = None
with tempfile.TemporaryDirectory() as directory:
directory = Path(directory)
try:
while True:
try:
data = snapshot(source)
source_error = None
if data is not None and data != previous:
previous = data
candidate = revision % ((1 << 32) - 1) + 1
try:
compiled = compile_packet(compiler, data, candidate, directory)
packet, revision = compiled, candidate
print(f'revision {revision}: compiled', flush=True)
except (ValueError, subprocess.TimeoutExpired) as error:
print(error, file=sys.stderr, flush=True)
except (OSError, ValueError) as error:
message = str(error)
if message != source_error:
print(message, file=sys.stderr, flush=True)
source_error = message
try:
if connection is None:
if time.monotonic() < next_connection:
time.sleep(interval)
continue
connection = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
connection.settimeout(1)
connection.connect(str(endpoint))
connection.settimeout(interval)
flight = None
completed = None
sent = 0.0
replies.clear()
if packet is not None and flight is None and revision != completed:
connection.settimeout(180)
connection.sendall(packet)
connection.settimeout(interval)
flight = revision
sent = time.monotonic()
if flight is not None and time.monotonic() - sent > 180:
raise TimeoutError('guest acknowledgement timed out')
try:
chunk = connection.recv(4096)
if not chunk:
raise ConnectionError('guest disconnected')
replies.extend(chunk)
except socket.timeout:
pass
while len(replies) >= 16:
if replies[:4] != MAGIC:
del replies[0]
continue
_, received, status, active = struct.unpack('<4sIII', replies[:16])
del replies[:16]
if received == 0 and status == 0:
completed = None
flight = None
elif received == flight:
completed = received
flight = None
print(f'revision {received}: ' + ('accepted' if status == 1 else f'rejected ({status}); active {active}'),
flush=True)
except OSError as error:
if connection is not None:
connection.close()
if sent:
print(str(error), file=sys.stderr, flush=True)
next_connection = time.monotonic() + (6 if sent else interval)
connection = None
sent = 0.0
time.sleep(interval)
finally:
if connection is not None:
connection.close()
def main():
parser = argparse.ArgumentParser()
parser.add_argument('source', type=Path)
parser.add_argument('--socket', required=True, type=Path)
parser.add_argument('--compiler', type=Path, default=ROOT / '.build/remy')
parser.add_argument('--interval', type=float, default=0.25)
args = parser.parse_args()
if not 0.01 <= args.interval <= 10:
parser.error('interval must be between 0.01 and 10 seconds')
if not args.compiler.is_file() or not os.access(args.compiler, os.X_OK):
parser.error('build an executable compiler with make first')
try:
watch(args.source, args.socket, args.compiler.resolve(), args.interval)
except KeyboardInterrupt:
pass
if __name__ == '__main__':
main()