Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,57 @@ shows you how to drive the shell.

---

## v2.7.5

**Fixed**

- **The shell notices a release published since its last check** (#62). The
report was a screenshot:

hellish 2.7.3 ...
✓ 2.7.3 up to date · via user binary · 50m ago

2.7.4 was out. The shell had checked 50 minutes earlier, when 2.7.3 really
was the newest thing there was — and the check interval was a flat 24
hours, so it would not look again until the next day. Every session in
between reported "up to date" with complete confidence, and the only way
to find out was typing `update` by hand: the exact chore a background
check exists to remove.

The interval is now adaptive, because the two states are not the same
question. When an update is **already known pending** there is nothing
left to learn — the badge is on your prompt and the banner has said so —
and it keeps the long interval. When the shell **believes it is current**,
that is the only state in which a release can exist without it knowing, so
it looks again every quarter of an hour instead.

The asymmetry is what keeps it cheap: the frequent interval applies only
while there is genuinely something to find, and stops the moment it is
found. If you have an update pending, this changes nothing — still one
request a day.

Two guards came with it, because "check more often" must not become
"check on every shell". A failed check now backs off like a successful
one, instead of re-firing on every startup forever on a machine that
cannot reach the release server. And the attempt is claimed before the
fork, so twenty terminals opened at once make one request rather than
twenty.

Unchanged: the check is a detached child and the prompt never waits on the
network. A dead release server still costs the shell nothing.

**Tests**

`update_freshness_test.py` drives real ptys against a counting local release
server — no network, so it is deterministic anywhere. It pins the report
itself, the discovering session announcing the release without a restart,
the badge on the next one, the absence of a re-check when an update is
already known, six concurrent shells making at most two requests, a failed
check recording the attempt but not claiming a success, and startup timing
against a black-holed endpoint.

---

## v2.7.4

Two bug reports from real sessions. One of them could leave your terminal
Expand Down
9 changes: 9 additions & 0 deletions incs/update.h
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ typedef struct s_upd_state
long header_rev;
char header_ver[64];
char announced[64];
long attempted;
} t_upd_state;

/* Load the persisted update state; zeroes `s` and returns 0 when absent. */
Expand Down Expand Up @@ -118,6 +119,14 @@ int read_cached_latest(char *out, size_t n);
fetches the latest tag from GitHub and rewrites the cache. Never blocks. */
void maybe_spawn_update_check(t_shell *state);

/* Should we ask the release server again, and may we? See update_gate.c:
the interval is short while we believe we are current and long once an
update is already known, and the attempt is claimed before forking so
many shells starting at once make one request. */
long check_interval(const t_upd_state *s);
int cache_is_fresh(void);
void claim_attempt(void);

/* The background worker: fetch the latest tag and write it to the cache. */
void run_bg_update_check(void);

Expand Down
2 changes: 1 addition & 1 deletion incs/version.h
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@

/* The single source of truth for the running shell's version. Bumped in step
with the git tag / GitHub release / npm package / docker image. */
# define HELLISH_VERSION "2.7.4"
# define HELLISH_VERSION "2.7.5"

/* Where releases live; used by the `update` builtin and the daily check. */
# define HELLISH_REPO "Univers42/hellish"
Expand Down
2 changes: 1 addition & 1 deletion npm/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "hellish-shell",
"version": "2.7.4",
"version": "2.7.5",
"description": "hellish — a fast, POSIX-compliant shell, with horns",
"bin": {
"hellish": "bin/hellish.js"
Expand Down
21 changes: 1 addition & 20 deletions src/platform/posix/update_cache.c
Original file line number Diff line number Diff line change
Expand Up @@ -40,26 +40,6 @@ long update_last_check_age(void)
return ((long)time(NULL) - s.checked);
}

/* True when the last check is recent enough to skip a new one. A missing
record is "stale", so the very first interactive run starts a check.
HELLISH_UPDATE_TTL overrides the interval (seconds) -- the test suite
uses it to force a re-check without waiting a day. */
static int cache_is_fresh(void)
{
long age;
const char *ttl;
long limit;

age = update_last_check_age();
if (age < 0)
return (0);
limit = 86400;
ttl = getenv("HELLISH_UPDATE_TTL");
if (ttl && *ttl)
limit = ft_atoi(ttl);
return (age <= limit);
}

/* Interactive only: if the last check is stale, fork a fully detached child
(double-fork) to refresh it in the background. Returns at once; the prompt
is never delayed by the network, which is the hard requirement in issue
Expand All @@ -73,6 +53,7 @@ void maybe_spawn_update_check(t_shell *state)
return ;
if (getenv("HELLISH_NO_UPDATE_CHECK") || cache_is_fresh())
return ;
claim_attempt();
pid = fork();
if (pid != 0)
{
Expand Down
88 changes: 88 additions & 0 deletions src/platform/posix/update_gate.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/* ************************************************************************** */
/* */
/* ::: :::::::: */
/* update_gate.c :+: :+: :+: */
/* +:+ +:+ +:+ */
/* By: dlesieur <dlesieur@student.42.fr> +#+ +:+ +#+ */
/* +#+#+#+#+#+ +#+ */
/* Created: 2026/08/23 00:00:00 by dlesieur #+# #+# */
/* Updated: 2026/08/23 00:00:00 by dlesieur ### ########.fr */
/* */
/* ************************************************************************** */

#include "update.h"
#include <unistd.h>
#include <time.h>

/* Split from update_cache.c only because the norm caps a file at 5
functions. This is the whole of "should we look again, and may we". */

/* How long to wait before asking again, and the two answers are not the
same question.

ALREADY PENDING: nothing left to learn. The badge is on the prompt and
the banner has announced it; a second opinion changes nothing a user can
see. A day is plenty.

BELIEVED CURRENT: this is the ONLY state in which a release can exist
without us knowing, so it is the only one where asking buys anything.
A flat day here is what produced issue #62 -- checked at noon, 2.7.4
published at half past, and every session until the next day reported
"up to date" with total confidence. A quarter of an hour instead.

The asymmetry is what keeps it cheap: the frequent interval applies only
while there is genuinely something to find, and stops the moment it is
found. HELLISH_UPDATE_TTL still overrides both, which is how the older
tests force a re-check without waiting. */
long check_interval(const t_upd_state *s)
{
const char *ttl;

ttl = getenv("HELLISH_UPDATE_TTL");
if (ttl && *ttl)
return (ft_atoi(ttl));
if (update_available(s))
return (86400);
return (900);
}

/* True when the last ATTEMPT is recent enough to skip a new one.

Attempts, not successes. run_bg_update_check() writes `checked` only
after it has actually learned something, so keying the interval off it
meant a machine that could not reach the release server re-forked a
check on every single startup, forever -- the one shape of this code
that really would hammer. `attempted` backs off on failure too, while
`checked` keeps its meaning of "last time we learned something", which
is what the banner's "50m ago" reports. A state file from an older
hellish has no `attempted`, so fall back to `checked` rather than
treating it as never-attempted and stampeding on first run. */
int cache_is_fresh(void)
{
t_upd_state s;
long last;

if (!update_state_load(&s))
return (0);
last = s.attempted;
if (last <= 0)
last = s.checked;
if (last <= 0)
return (0);
return ((long)time(NULL) - last <= check_interval(&s));
}

/* Claim the check before forking, in the PARENT.
Twenty terminals opened at once would otherwise all read the same stale
record and all fork their own request. Recording the attempt here makes
the first one win and the other nineteen see a fresh cache. It is one
small temp-file-and-rename, at most once per interval, and it happens
off the prompt's path entirely. */
void claim_attempt(void)
{
t_upd_state s;

update_state_load(&s);
s.attempted = (long)time(NULL);
update_state_save(&s);
}
2 changes: 2 additions & 0 deletions src/platform/posix/update_state.c
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ static void state_set(t_upd_state *s, char *key, char *val)
ft_strlcpy(s->header_ver, val, sizeof(s->header_ver));
else if (!ft_strcmp(key, "announced"))
ft_strlcpy(s->announced, val, sizeof(s->announced));
else if (!ft_strcmp(key, "attempted"))
s->attempted = ft_atoi(val);
}

/* Split the file into lines and feed each key=value pair to state_set. */
Expand Down
18 changes: 13 additions & 5 deletions src/platform/posix/update_state2.c
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,18 @@
and a foreground `update` can run at the same time, and a half-written
record read by the prompt would announce a version that does not exist;
rename is atomic, so a reader sees either the old record or the new one. */
/* The record itself, one key=value per line. Split out only because the
norm caps a function at 25 lines and the field list keeps growing. */
static int format_record(const t_upd_state *s, char *buf, size_t n)
{
return (ft_snprintf(buf, n, "latest=%s\nchecked=%d\n"
"notified=%d\nheader_shown=%d\nheader_rev=%d\n"
"header_ver=%s\nannounced=%s\nattempted=%d\n", s->latest,
(int)s->checked, (int)s->notified, (int)s->header_shown,
(int)s->header_rev, s->header_ver, s->announced,
(int)s->attempted));
}

int update_state_save(const t_upd_state *s)
{
char path[512];
Expand All @@ -43,11 +55,7 @@ int update_state_save(const t_upd_state *s)
fd = open(tmp, O_CREAT | O_TRUNC | O_WRONLY, 0644);
if (fd < 0)
return (0);
len = ft_snprintf(buf, sizeof(buf), "latest=%s\nchecked=%d\n"
"notified=%d\nheader_shown=%d\nheader_rev=%d\n"
"header_ver=%s\nannounced=%s\n", s->latest, (int)s->checked,
(int)s->notified, (int)s->header_shown, (int)s->header_rev,
s->header_ver, s->announced);
len = format_record(s, buf, sizeof(buf));
if (len <= 0 || write(fd, buf, (size_t)len) != len)
return (close(fd), unlink(tmp), 0);
close(fd);
Expand Down
Loading
Loading