Pulp 1.88.0
Cross-platform audio plugin framework
Loading...
Searching...
No Matches
reload_controller.hpp
Go to the documentation of this file.
1#pragma once
2
40
44
45#include <array>
46#include <atomic>
47#include <cstddef>
48#include <cstdint>
49#include <filesystem>
50#include <fstream>
51#include <optional>
52#include <string>
53#include <system_error>
54#include <utility>
55
56#if defined(_WIN32)
57#include <process.h>
58#else
59#include <cerrno>
60#include <csignal>
61#include <unistd.h>
62#endif
63
64namespace pulp::format::reload {
65
66// True if `pid` names a live process. On POSIX, kill(pid,0) succeeds (0) or
67// fails with EPERM when the process exists but we lack permission; ESRCH means
68// it's gone. On Windows we have no cheap portable check, so we report "not
69// alive" and rely on the fact that a loaded module's file is locked — remove()
70// then fails harmlessly, so we never delete a live instance's mapped image.
71inline bool staged_pid_is_alive(long pid) {
72#if defined(_WIN32)
73 (void)pid;
74 return false;
75#else
76 if (::kill(static_cast<::pid_t>(pid), 0) == 0) return true;
77 return errno == EPERM;
78#endif
79}
80
81// Reap stale staged copies (`<stem>.initial.<pid>.*` / `<stem>.reload.<pid>.*`)
82// left by DEAD processes — dev-loop litter that accumulates because each run
83// stages under a unique pid+counter name (ReloadableShell::stage_initial and
84// ReloadController::stage_and_reload). Best-effort and startup-only. Files whose
85// embedded PID is still a live process are left untouched, so a concurrently
86// running instance's staged images are never removed.
87inline void reap_stale_staged(const std::filesystem::path& dir,
88 const std::string& stem) {
89 namespace fs = std::filesystem;
90 std::error_code ec;
91 if (!fs::is_directory(dir, ec)) return;
92 const std::string prefixes[2] = {stem + ".initial.", stem + ".reload."};
93 for (fs::directory_iterator it(dir, ec), end; !ec && it != end; it.increment(ec)) {
94 const std::string fn = it->path().filename().string();
95 std::size_t pos = std::string::npos;
96 for (const auto& p : prefixes)
97 if (fn.rfind(p, 0) == 0) { pos = p.size(); break; }
98 if (pos == std::string::npos) continue;
99 const std::size_t dot = fn.find('.', pos); // pid runs up to the next '.'
100 if (dot == std::string::npos || dot == pos) continue;
101 long pid = 0;
102 const std::string pid_str = fn.substr(pos, dot - pos);
103 try { pid = std::stol(pid_str); } catch (...) { continue; }
104 if (pid <= 0 || staged_pid_is_alive(pid)) continue; // keep live-owned files
105 std::error_code rm;
106 fs::remove(it->path(), rm); // best-effort
107 }
108}
109
111public:
114 ReloadController(ReloadSession& session, std::filesystem::path logic_path,
115 std::filesystem::path stage_dir = {},
116 ReloadTrustPolicy trust_policy = {})
117 : session_(session), path_(std::move(logic_path)),
118 stage_dir_(stage_dir.empty() ? path_.parent_path() : std::move(stage_dir)),
119 trust_policy_(std::move(trust_policy)) {
120 // Startup housekeeping: clear dead-process staged litter so a long dev
121 // history doesn't leave hundreds of <stem>.initial.*/<stem>.reload.*
122 // copies behind (bounds staged-copy accumulation across restarts).
123 reap_stale_staged(stage_dir_, path_.stem().string());
124 }
125
129 std::optional<ReloadOutcome> poll() {
130 const auto mtime = current_mtime();
131 if (!mtime) return std::nullopt; // missing / unreadable — nothing to do
132 if (!baseline_set_) { // first sight: record, don't reload
133 last_mtime_ = *mtime;
134 if (const auto h = hash_file(path_)) { last_hash_ = *h; hash_valid_ = true; }
135 baseline_set_ = true;
136 return std::nullopt;
137 }
138 if (*mtime == last_mtime_) return std::nullopt; // unchanged mtime
139 last_mtime_ = *mtime; // act once per distinct mtime
140
141 // Content-hash gate: an mtime bump does not prove the BYTES
142 // changed. Distinguish the three non-reload cases from a real edit:
143 // - empty / unreadable right now (a rebuild mid-write) → skip; the next
144 // completed write (new mtime) is retried.
145 // - identical content (a `touch`, or a rebuild that produced byte-
146 // identical output) → skip; no redundant stage + dlopen + gate churn.
147 // - genuinely new bytes → record the hash and reload.
148 const auto hash = hash_file(path_);
149 if (!hash) return std::nullopt; // empty / unreadable → not a real version yet
150 if (hash_valid_ && *hash == last_hash_) return std::nullopt; // unchanged content
151 last_hash_ = *hash;
152 hash_valid_ = true;
153 return stage_and_reload();
154 }
155
159 if (const auto mtime = current_mtime()) {
160 last_mtime_ = *mtime;
161 baseline_set_ = true;
162 }
163 if (const auto hash = hash_file(path_)) { // resync the content baseline too
164 last_hash_ = *hash;
165 hash_valid_ = true;
166 }
167 return stage_and_reload();
168 }
169
170 const std::filesystem::path& path() const { return path_; }
171 std::uint64_t reload_attempts() const { return attempts_; }
172
175 struct Baseline {
176 std::filesystem::file_time_type mtime{};
177 std::uint64_t hash = 0;
178 };
179 std::optional<Baseline> baseline() const {
180 if (!baseline_set_ || !hash_valid_)
181 return std::nullopt;
182 return Baseline{last_mtime_, last_hash_};
183 }
184
190 void set_baseline(const Baseline& b) {
191 last_mtime_ = b.mtime;
192 last_hash_ = b.hash;
193 hash_valid_ = true;
194 baseline_set_ = true;
195 }
196
198 static std::optional<std::uint64_t> content_hash(const std::filesystem::path& p) {
199 return hash_file(p);
200 }
201
202private:
203 std::optional<std::filesystem::file_time_type> current_mtime() const {
204 std::error_code ec;
205 const auto t = std::filesystem::last_write_time(path_, ec);
206 if (ec) return std::nullopt;
207 return t;
208 }
209
210 // FNV-1a 64-bit over the watched file's bytes — a content fingerprint for
211 // change detection only (not security; the fail-closed ABI/fingerprint/
212 // contract gates are the trust boundary). Returns nullopt for an unreadable
213 // OR empty file (an empty logic image is never a real version — usually a
214 // rebuild mid-write), so the caller skips rather than acting on it.
215 static std::optional<std::uint64_t> hash_file(const std::filesystem::path& p) {
216 std::ifstream f(p, std::ios::binary);
217 if (!f) return std::nullopt;
218 std::uint64_t h = 1469598103934665603ULL; // FNV offset basis
219 std::array<char, 64 * 1024> buf{};
220 std::size_t total = 0;
221 while (f) {
222 f.read(buf.data(), static_cast<std::streamsize>(buf.size()));
223 const std::streamsize got = f.gcount();
224 for (std::streamsize i = 0; i < got; ++i) {
225 h ^= static_cast<unsigned char>(buf[static_cast<std::size_t>(i)]);
226 h *= 1099511628211ULL; // FNV prime
227 }
228 total += static_cast<std::size_t>(got);
229 }
230 if (f.bad() || total == 0) return std::nullopt; // unreadable or empty
231 return h;
232 }
233
234 // SHA-256 (hex) of a file's bytes, for confirming the staged image matches the
235 // signed manifest hash. std::nullopt if the file can't be read.
236 static std::optional<std::string> sha256_of_file(const std::filesystem::path& p) {
237 std::ifstream f(p, std::ios::binary);
238 if (!f) return std::nullopt;
239 const std::string bytes((std::istreambuf_iterator<char>(f)),
240 std::istreambuf_iterator<char>());
241 return runtime::sha256_hex(bytes);
242 }
243
244 // Copy the watched file to a fresh, uniquely-named path and reload THAT, so
245 // the loader reads the new bytes rather than returning a cached image for a
246 // path it already opened.
247 ReloadOutcome stage_and_reload() {
248 ++attempts_;
249 // require_signed enforcement (opt-in; default OFF = the frictionless dev
250 // loop). Verify the WATCHED file's signed pack BEFORE staging, then confirm
251 // AFTER the stage copy that the staged bytes we actually load still match the
252 // signed hash. The copy re-reads path_, so verifying only the source would
253 // leave a verify→copy TOCTOU (a racing writer could swap in unsigned bytes
254 // between the check and the copy); re-hashing the staged image closes it,
255 // mirroring pack_install's verify-the-published-bytes rule. Fail closed
256 // throughout — a refusal returns without loading anything.
257 std::string expected_signed_sha; // signed SHA-256 of the watched member
258 if (trust_policy_.require_signed) {
259 auto decision = resolve_reload_trust(path_, trust_policy_);
260 if (auto* refused = std::get_if<ReloadOutcome>(&decision)) return *refused;
261 if (auto* trust = std::get_if<SwapPackTrust>(&decision)) {
262 if (auto rejected = verify_pack_before_load(*trust, path_.string()))
263 return *rejected;
264 const std::string member = path_.filename().string();
265 for (const auto& f : trust->manifest.files)
266 if (f.path == member) { expected_signed_sha = f.sha256_hex; break; }
267 if (expected_signed_sha.empty())
269 "require_signed: watched file is not a named member of its "
270 "signed manifest"};
271 }
272 }
273 // Stage to a name unique per-INSTANCE and per-process. Two shells
274 // watching the same logic_path share a default stage_dir (the watched
275 // file's directory), so a name keyed only on a per-controller counter
276 // (both start at 1) collides: the second instance's copy_file races the
277 // first's dlopen of the same path, and on Linux an overwrite-while-mapped
278 // corrupts the other instance's live image (see the header note + the
279 // identical hazard ReloadableShell::stage_initial guards). PID
280 // disambiguates processes; the process-wide atomic counter disambiguates
281 // instances and successive reloads within a process.
282 static std::atomic<std::uint64_t> global_counter{0};
283 const long pid =
284#if defined(_WIN32)
285 static_cast<long>(::_getpid());
286#else
287 static_cast<long>(::getpid());
288#endif
289 const auto staged =
290 stage_dir_ /
291 (path_.stem().string() + ".reload." + std::to_string(pid) + "." +
292 std::to_string(global_counter.fetch_add(1, std::memory_order_relaxed)) +
293 path_.extension().string());
294 std::error_code ec;
295 std::filesystem::copy_file(
296 path_, staged, std::filesystem::copy_options::overwrite_existing, ec);
297 if (ec) {
299 "stage copy failed: " + ec.message()};
300 }
301 // Close the verify→copy TOCTOU: the bytes we are about to load are the
302 // staged copy, so re-hash IT against the signed member hash. If path_ was
303 // swapped between the verify above and the copy, the staged bytes won't
304 // match and we refuse (and drop the untrusted stage copy) before any load.
305 if (!expected_signed_sha.empty()) {
306 const auto staged_sha = sha256_of_file(staged);
307 if (!staged_sha || *staged_sha != expected_signed_sha) {
308 std::error_code rm;
309 std::filesystem::remove(staged, rm);
311 "require_signed: staged bytes do not match the signed hash "
312 "(source changed after verification) — refused"};
313 }
314 }
315 // Best-effort reap THIS instance's previous staged copy so a long dev
316 // session doesn't accumulate one file per reload. Safe: on POSIX the
317 // prior image was dlopen'd (LeakPolicy::Retain) and its mapping survives
318 // unlink, so the fading-out processor keeps running; on Windows a loaded
319 // module's file is locked, so remove() fails and is ignored. Only reaps
320 // our own files — never another instance's, and never the just-staged
321 // one. (Cross-restart orphans from dead PIDs are a separate startup-reap
322 // follow-up under the staged-file bookkeeping.)
323 if (!last_staged_.empty() && last_staged_ != staged) {
324 std::error_code rm_ec;
325 std::filesystem::remove(last_staged_, rm_ec);
326 }
327 last_staged_ = staged;
328 return session_.reload(staged.string());
329 }
330
331 ReloadSession& session_;
332 std::filesystem::path path_;
333 std::filesystem::path stage_dir_;
334 ReloadTrustPolicy trust_policy_; // opt-in require_signed enforcement (default OFF)
335 std::filesystem::file_time_type last_mtime_{};
336 std::filesystem::path last_staged_; // this instance's prior staged copy (reaped on next stage)
337 std::uint64_t last_hash_ = 0; // content hash last acted on
338 bool hash_valid_ = false; // last_hash_ has been established
339 bool baseline_set_ = false;
340 std::uint64_t attempts_ = 0;
341};
342
343} // namespace pulp::format::reload
std::uint64_t reload_attempts() const
Definition reload_controller.hpp:171
void set_baseline(const Baseline &b)
Seed the baseline with the version that is actually live, so the first poll() compares against it ins...
Definition reload_controller.hpp:190
std::optional< Baseline > baseline() const
Definition reload_controller.hpp:179
ReloadOutcome reload_now()
Force a reload regardless of mtime (e.g.
Definition reload_controller.hpp:158
std::optional< ReloadOutcome > poll()
Poll once.
Definition reload_controller.hpp:129
const std::filesystem::path & path() const
Definition reload_controller.hpp:170
static std::optional< std::uint64_t > content_hash(const std::filesystem::path &p)
Content fingerprint used for change detection (see hash_file).
Definition reload_controller.hpp:198
ReloadController(ReloadSession &session, std::filesystem::path logic_path, std::filesystem::path stage_dir={}, ReloadTrustPolicy trust_policy={})
Definition reload_controller.hpp:114
Owns the session-stable state for a series of hot reloads of one plugin instance — the slot,...
Definition reload_transaction.hpp:407
Definition build_fingerprint.hpp:23
bool staged_pid_is_alive(long pid)
Definition reload_controller.hpp:71
std::optional< ReloadOutcome > verify_pack_before_load(const SwapPackTrust &trust, const std::string &library_path)
Verify trust on the RAW bytes on disk and confirm library_path is a member of the verified pack — the...
Definition reload_transaction.hpp:128
void reap_stale_staged(const std::filesystem::path &dir, const std::string &stem)
Definition reload_controller.hpp:87
ReloadTrustDecision resolve_reload_trust(const std::filesystem::path &logic_path, const ReloadTrustPolicy &policy)
Definition reload_trust_policy.hpp:42
@ end
Definition editor_host.hpp:354
@ f
Definition pitch.hpp:25
std::string sha256_hex(const uint8_t *data, size_t size)
Compute SHA-256 hash as hex string (64 chars).
@ rejected
Definition rmlui_host_bridge.hpp:21
@ h
Definition input_events.hpp:39
STL namespace.
The verify-before-commit reload transaction — ties the Phase-0 primitives into one operation: load a ...
The version the controller compares against: the watched file's mtime and content hash it last acted ...
Definition reload_controller.hpp:175
std::filesystem::file_time_type mtime
Definition reload_controller.hpp:176
std::uint64_t hash
Definition reload_controller.hpp:177
Definition reload_transaction.hpp:63
@ RejectedIntegrity
Swap-pack per-file SHA-256 failed, or the load target was not a verified member of the pack; rejected...
Definition reload_transaction.hpp:68
@ RejectedLoadFailed
dlopen / LoadLibrary failed.
Definition reload_transaction.hpp:72
Definition reload_trust_policy.hpp:26