Skip to content

Commit 2ab1538

Browse files
committed
feat(compile,runtime): let a compiled app carry its own update check
Perry's CLI has checked for updates for a long time; an app it builds could not, so shipping one meant hand-writing a version check or shipping none. A perry.update block in package.json -- or [update] in perry.toml, which wins key by key -- is validated at compile time and baked into the executable as a small blob. The runtime reads it at the top of main, applies every gate, and prints a two-line notice on stderr when a previous run recorded something newer. The network refresh that records it is the next slice, so a first run is silent. With no block configured NOTHING is emitted: not an empty blob, not a disabled one. A binary that configures no updates is byte-identical to one built before this existed, and the end-to-end check asserts it. The blob is part of the object-cache fingerprint, without which adding the block and rebuilding would serve the cached entry object and ship a binary with no update check while reporting success. Validation is a build failure rather than a warning. A typo in an update URL is found either by the person who typed it, now, or by their users, in production, as silence. HTTPS is required (loopback excepted), each source must carry the keys it reads, a zero check interval is rejected, and an app with no version to compare against is caught. State lives in the platform's cache location via dirs, keyed per app so two Perry-built programs never share a throttle, with the app id sanitized into one path component because it is a manifest value that becomes a path. Three lessons from the CLI's own review are applied here rather than rediscovered: the notify interval is keyed to the announced VERSION so it cannot swallow the next release, the state file is written to a per-write temporary name so two instances cannot rename over each other, and an unreadable timestamp notifies rather than silencing updates forever. Two more are specific to this half: the notice goes to stderr because an app's stdout belongs to the app, and control characters are stripped because a release name is attacker-influenceable terminal input. An unparseable version never reads as newer -- node-smol compared against a hardcoded 0.0.0, which made every release look newer than the running binary.
1 parent 0a2bf15 commit 2ab1538

12 files changed

Lines changed: 2044 additions & 1 deletion

File tree

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
### Added
2+
3+
**An application Perry compiles can now carry its own update check.** Perry's
4+
CLI has checked for updates for a long time; an app it *builds* could not, so
5+
shipping one meant the author wrote a version check by hand or shipped none and
6+
hoped users noticed.
7+
8+
```json
9+
{ "perry": { "update": {
10+
"source": "npm", "package": "myapp", "command": "self-update"
11+
} } }
12+
```
13+
14+
A `perry.update` block — or an `[update]` table in perry.toml, which wins key by
15+
key — is validated at compile time and baked into the executable as a small
16+
blob. The runtime reads it at the top of `main`, before any user code.
17+
18+
**With no block configured, nothing is emitted.** Not an empty blob, and not a
19+
disabled one: a binary that configures no updates is byte-identical to one built
20+
before this existed. A feature whose off-state still emits code is one you
21+
cannot prove is off.
22+
23+
<details>
24+
<summary><b>Validation is a build failure, on purpose</b></summary>
25+
26+
A typo in an update URL is discovered either by the person who typed it, at
27+
build time, with a message naming the key — or by their users, in production, as
28+
silence. So these are errors rather than warnings:
29+
30+
- a URL must be `https://`, with loopback allowed for local testing. Plain HTTP
31+
is refused rather than warned about, because an on-path attacker can suppress
32+
a legitimate update by answering "you are current", and build-output warnings
33+
are not where that gets noticed;
34+
- each source needs the keys it actually reads — `url` for `gh-releases` and
35+
`custom`, `package` for the npm-shaped ones;
36+
- a zero check interval is rejected, since it would ask on every run; removing
37+
the block is how you disable checks;
38+
- an app with no version has nothing to compare against, so that is caught too.
39+
40+
`enabled = false` keeps the settings on disk and emits nothing, rather than
41+
embedding a disabled block complete with its URL and startup call.
42+
</details>
43+
44+
<details>
45+
<summary><b>What the runtime half does, and what it deliberately does not</b></summary>
46+
47+
It parses the blob and applies every gate that decides whether a check may
48+
happen: the app's own opt-out variable, `PERRY_NO_UPDATE_CHECK`, the
49+
ecosystem-wide `NO_UPDATE_NOTIFIER`, `CI` and `CONTINUOUS_INTEGRATION`, a
50+
non-terminal stderr, and the app's own update command — so `app self-update`
51+
does not check on its way to updating.
52+
53+
It does **not** yet reach the network or print anything. That split is
54+
deliberate: the gates are where this feature can go wrong quietly. A check that
55+
fires in CI, or in a script parsing the app's output, is a bug that surfaces as
56+
somebody else's flaky pipeline, so they are worth landing and testing ahead of
57+
the code that would exercise them.
58+
59+
A blob whose schema this build does not recognize is ignored rather than read
60+
field by field. It is emitted by the same Perry that compiled the binary, so a
61+
mismatch means something is wrong upstream, and guessing at a moved layout would
62+
run a network check with settings nobody wrote.
63+
</details>
64+
65+
<details>
66+
<summary><b>The cache key, which is where this would have broken silently</b></summary>
67+
68+
The blob is part of the object-cache fingerprint. Without that, adding
69+
`perry.update` to a project and rebuilding incrementally would serve the cached
70+
entry object from before — and the binary would ship with no update check while
71+
the build reported success. Same class as the `dbgloc` and `fmath` entries the
72+
cache file already documents.
73+
74+
The embed is skipped for a dylib, for the same reason the App Group init is:
75+
there is no `main` to put a prelude in.
76+
</details>
77+
78+
<details>
79+
<summary><b>Tests</b></summary>
80+
81+
22 new. Ten on the compiler side cover the parse and every validation rule,
82+
including that perry.toml overrides package.json key by key while leaving keys it
83+
does not set alone, and that the blob stamps its schema and omits unset optionals
84+
rather than writing nulls. Twelve on the runtime side cover the blob reader and
85+
every gate, including that a key name appearing inside a *value* is not
86+
mismatched, and that an app with no config reports "not configured" instead of
87+
"go ahead" — an inversion the test caught during development.
88+
89+
Verified end to end: a configured project's binary contains the blob, the same
90+
project without the block produces one that does not, the configured binary runs
91+
normally, and a plain-HTTP URL fails the build with a message naming the key.
92+
93+
`cargo test -p perry`: 912 passed. `cargo test -p perry-runtime`: 2064 passed.
94+
Both zero failures.
95+
96+
### Added (second slice)
97+
98+
**The notify half now works from recorded state.** A compiled app with an
99+
embedded block reads its own per-app state file at startup and prints a
100+
two-line notice on stderr when a newer version was recorded by a previous run.
101+
The network refresh that records it is the next slice, so a first run is silent —
102+
which is the right way round.
103+
104+
State lives in the platform's own cache location, resolved through `dirs` so it
105+
asks the real APIs (Known Folders on Windows, the Foundation search paths on
106+
macOS) rather than trusting environment variables a launcher or service manager
107+
may not have set. Keyed per app, so two Perry-built programs never share a
108+
throttle: one app's notice silencing another's would be invisible and
109+
maddening. The app id is sanitized into a single path component — it is a
110+
manifest value that becomes a path, and `..` is cheaper to make impossible than
111+
to reason about.
112+
113+
<details>
114+
<summary><b>Three lessons from the CLI's own review, applied here first</b></summary>
115+
116+
The interval throttles repeats of the **same** release, keyed on the version and
117+
not only on a timestamp. Keyed on time alone it swallows the next release
118+
whenever that arrives inside the window, so an interval set to stop nagging
119+
about one version would also hide the version that fixed it.
120+
121+
The state file is written to a per-write temporary name and renamed over the
122+
target. Two instances of the same app can run at once, and one shared temporary
123+
name lets each rename a file the other is still writing.
124+
125+
An unreadable timestamp notifies rather than staying silent, because silence on
126+
a damaged file would hide updates indefinitely.
127+
128+
Two more that are specific to this half. The notice goes to **stderr**, always:
129+
an app's stdout belongs to the app. And every value in it arrived in a network
130+
document, so control characters are stripped — a release name is
131+
attacker-influenceable terminal input, and a notice must not be able to repaint
132+
somebody's screen.
133+
134+
An unparseable version never reads as newer. node-smol's equivalent compared
135+
against a hardcoded `"0.0.0"`, which made every release look newer than the
136+
running binary; returning "unknown" is what avoids that.
137+
</details>
138+
139+
<details>
140+
<summary><b>Tests</b></summary>
141+
142+
16 more, bringing this module to 28. Beyond the units — version comparison,
143+
the sanitizer against five hostile ids, per-platform directory rules, the
144+
throttle, control-character stripping, a real file round trip, and that the
145+
stamp writer and reader agree — there is a **wiring test** that drives the whole
146+
startup path: blob in, notice out, state advanced, second run quiet.
147+
148+
That one exists because everything else here is a piece tested in isolation, and
149+
a feature whose pieces all pass while the path between them is broken is exactly
150+
what ships doing nothing.
151+
152+
`cargo test -p perry-runtime`: 2080 passed. `cargo test -p perry`: 912 passed.
153+
Both zero failures.
154+
155+
### Documentation
156+
157+
New page `docs/src/cli/app-updates.md`, written for the app author rather than
158+
for Perry's own maintainers: the smallest configuration that does something
159+
useful, every key, how to choose a source, the mistakes that fail the build and
160+
why each is an error rather than a warning, the cases where a user's run will not
161+
check at all, where the state file lives per platform, and how to give your own
162+
users an off switch.
163+
164+
It states two things an app author needs to be able to rely on: their app's
165+
stdout is never touched, and the notify throttle is keyed to the version — so a
166+
week-long interval set to stop nagging about one release does not also hide the
167+
patch that fixes it.

crates/perry-codegen/src/codegen/entry.rs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,26 @@ pub(super) fn compile_module_entry(
375375
.filter(|s| !s.is_empty())
376376
.map(|suite| llmod.add_string_constant(suite))
377377
};
378+
// The `perry.update` blob (Phase B): one string constant plus one
379+
// `perry_update_notify_startup(ptr, len)` call at the top of `main`, so
380+
// a configured app checks for its own updates without its author
381+
// writing a version check. Emitted ONLY when the project configures the
382+
// block — a binary with no update settings must be byte-identical to
383+
// one built before this existed, which `entry.rs`'s absence test pins.
384+
//
385+
// Skipped for a dylib for the same reason `app_group` is: there is no
386+
// `main` to put a prelude in, so the call would reference a startup
387+
// path that does not exist here.
388+
let update_init: Option<(String, usize)> = if is_dylib {
389+
None
390+
} else {
391+
cross_module
392+
.app_metadata
393+
.update_config
394+
.as_deref()
395+
.filter(|s| !s.is_empty())
396+
.map(|blob| llmod.add_string_constant(blob))
397+
};
378398
// i18n startup init: when the project configures `[i18n]`, bake the
379399
// configured locale-code list (and the optional `[i18n.currencies]`
380400
// map) into `main`'s prelude as a single `perry_i18n_init` call —
@@ -494,6 +514,17 @@ pub(super) fn compile_module_entry(
494514
&[(PTR, suite_ptr.as_str()), (I32, len_str.as_str())],
495515
);
496516
}
517+
// The update check runs before user code, so an app that exits
518+
// early still gets its notice, and so the per-app state directory
519+
// is resolved before anything can change the working directory.
520+
if let Some((const_name, byte_len)) = update_init.as_ref() {
521+
let blob_ptr = format!("@{}", const_name);
522+
let len_str = byte_len.to_string();
523+
blk.call_void(
524+
"perry_update_notify_startup",
525+
&[(PTR, blob_ptr.as_str()), (I32, len_str.as_str())],
526+
);
527+
}
497528
// i18n: register the configured locale list + resolve the runtime
498529
// locale BEFORE any module init runs, so module-top-level `t()`
499530
// calls and format wrappers already see the detected locale.

crates/perry-codegen/src/codegen/opts.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,13 @@ pub struct AppMetadata {
2020
/// `app_group_*` calls fall through to the runtime's "not configured"
2121
/// stub-warn diagnostic. Refs #1178.
2222
pub app_group: Option<String>,
23+
/// The validated `perry.update` block, as the JSON blob the runtime reads,
24+
/// or `None` when the project configures no update check.
25+
///
26+
/// `None` must emit NOTHING — not an empty blob and not a disabled one. A
27+
/// binary that configures no updates is byte-identical to one built before
28+
/// this existed, and `entry.rs`'s absence test asserts it.
29+
pub update_config: Option<String>,
2330
}
2431

2532
impl Default for AppMetadata {
@@ -29,6 +36,7 @@ impl Default for AppMetadata {
2936
build_number: 1,
3037
bundle_id: "com.perry.app".to_string(),
3138
app_group: None,
39+
update_config: None,
3240
}
3341
}
3442
}

crates/perry-codegen/src/runtime_decls/mod.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,10 @@ pub fn declare_phase1(module: &mut LlModule) {
5050
// the runtime always provides the symbol; main only emits the call
5151
// when `app_metadata.app_group` is `Some`.
5252
module.declare_function("perry_app_group_init", VOID, &[PTR, I32]);
53+
// Phase B: the embedded `perry.update` blob's startup entry point. Declared
54+
// unconditionally, like every other runtime symbol here — the CALL is what
55+
// `entry.rs` emits only for a configured project.
56+
module.declare_function("perry_update_notify_startup", VOID, &[PTR, I32]);
5357
// macOS asset-CWD fix: a macOS `.app` launched from Finder starts with
5458
// CWD=`/`, but the worker bundles assets into `Contents/Resources/`. The
5559
// `main` prelude calls this unconditionally; the runtime symbol no-ops on

crates/perry-runtime/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,7 @@ mod ui_harmonyos_stubs;
247247
/// target-aware branching. UI crates register their handlers here at
248248
/// startup. See module docs for the ohos-napi gating story.
249249
pub mod ui_text_registry;
250+
pub mod update_notify;
250251
pub mod util_abort;
251252
pub mod util_call_sites;
252253
pub mod util_debuglog;

0 commit comments

Comments
 (0)