Web: a launch behind an expired sign-in shows the sign-in instead of the kept build (#2151)

Thanks @nofuturekid.
This commit is contained in:
Thomas Kroll authored and GitHub committed 2026-10-17 17:54:29 -04:00
1 parent 7a3221d2dd
commit a11446fd17
2 files changed
+52 -7

No files matched your search

+12 -4
View File
@@ -13,8 +13,10 @@
// unchanged, the kept build starts; when a new build is deployed, it is downloaded first // unchanged, the kept build starts; when a new build is deployed, it is downloaded first
// (waiting at most UPDATE_TIMEOUT_MS; the download goes on in the background after that) and // (waiting at most UPDATE_TIMEOUT_MS; the download goes on in the background after that) and
// started. When the site has no `build.json` (the app was removed), launches go to the network, // started. When the site has no `build.json` (the app was removed), launches go to the network,
// the kept build answering when that fails. Each page then gets its files from the build it // the kept build answering when that fails. So they do when `build.json` is redirected or
// started (remembered in PAGES), never from another build with a file of the same name. // refused (a sign-in in front of the app): the browser then shows the sign-in. Each page then
// gets its files from the build it started (remembered in PAGES), never from another build
// with a file of the same name.
// //
// A cache is only deleted when nothing uses it: a download holds its lock (Web Locks, shared, // A cache is only deleted when nothing uses it: a download holds its lock (Web Locks, shared,
// in any tab or worker; the browser releases it when the download ends or its worker stops), and // in any tab or worker; the browser releases it when the download ends or its worker stops), and
@@ -158,9 +160,14 @@ function within(ms, promise) {
/** No app is deployed here any more (the server says `build.json` doesn't exist). */ /** No app is deployed here any more (the server says `build.json` doesn't exist). */
const GONE = "gone"; const GONE = "gone";
/** The deployed build's version and files, GONE, or null (unknown: offline, failing, invalid). */ /** The site wants a sign-in first (`build.json` is redirected, often to another origin, or refused). */
const SIGN_IN = "sign-in";
/** The deployed build's version and files, GONE, SIGN_IN, or null (unknown: offline, failing, invalid). */
async function deployed() { async function deployed() {
const response = await fetch(url("build.json"), { cache: "no-cache" }); // `manual`: a redirect to another origin would only fail (CORS), looking like being offline.
const response = await fetch(url("build.json"), { cache: "no-cache", redirect: "manual" });
if (response.type === "opaqueredirect" || response.status === 401 || response.status === 403) return SIGN_IN;
if (response.status === 404 || response.status === 410) return GONE; if (response.status === 404 || response.status === 410) return GONE;
const build = response.ok ? await response.json() : null; const build = response.ok ? await response.json() : null;
const valid = build && typeof build.version === "string" && Array.isArray(build.files); const valid = build && typeof build.version === "string" && Array.isArray(build.files);
@@ -182,6 +189,7 @@ async function launch(event) {
if (!kept) return [await fetch(event.request), NETWORK]; // Nothing kept yet (the first visit). if (!kept) return [await fetch(event.request), NETWORK]; // Nothing kept yet (the first visit).
extend(event, sweep()); extend(event, sweep());
const build = await within(NAVIGATE_TIMEOUT_MS, deployed()); const build = await within(NAVIGATE_TIMEOUT_MS, deployed());
if (build === SIGN_IN) return [await fetch(event.request), NETWORK];
if (build === GONE) { if (build === GONE) {
const page = await fetch(event.request).catch(() => null); const page = await fetch(event.request).catch(() => null);
return page && page.ok ? [page, NETWORK] : [kept, CACHE]; return page && page.ok ? [page, NETWORK] : [kept, CACHE];
+40 -3
View File
@@ -4,7 +4,7 @@
//! `/solidcraft/app/` (as on GitHub Pages, with its 10-minute HTTP caching) from a local server //! `/solidcraft/app/` (as on GitHub Pages, with its 10-minute HTTP caching) from a local server
//! that can also act offline, flaky, hung or mid-deployment, plus another copy of the app at //! that can also act offline, flaky, hung or mid-deployment, plus another copy of the app at
//! `/solidcraft/elsewhere/` on the same origin, and drives headless Chrome through the DevTools //! `/solidcraft/elsewhere/` on the same origin, and drives headless Chrome through the DevTools
//! protocol through these steps (twenty-one page launches, which failures are numbered by): //! protocol through these steps (twenty-two page launches, which failures are numbered by):
//! //!
//! 1. online, build v1: the page loads, the service worker takes over and keeps every file; //! 1. online, build v1: the page loads, the service worker takes over and keeps every file;
//! 2. offline: v1 starts from the kept copy; //! 2. offline: v1 starts from the kept copy;
@@ -33,7 +33,9 @@
//! 17. offline: v6 still starts from the kept copy; //! 17. offline: v6 still starts from the kept copy;
//! 18. a development build (`trunk serve`) on the same origin: it starts (no `build.json`) //! 18. a development build (`trunk serve`) on the same origin: it starts (no `build.json`)
//! while the worker retires, dropping everything kept; //! while the worker retires, dropping everything kept;
//! 19. the development build starts, from the network, with no worker. //! 19. the development build starts, from the network, with no worker;
//! 20. a sign-in in front of the app (everything redirects to another origin) while v6 is kept:
//! the launch shows the sign-in page, not the kept v6.
//! //!
//! The site is a small stand-in app (default) or a real web build (`--site dist/web`, after //! The site is a small stand-in app (default) or a real web build (`--site dist/web`, after
//! `trunk build --release`); a real build must hold the `sw.js` and `build.json` the generator //! `trunk build --release`); a real build must hold the `sw.js` and `build.json` the generator
@@ -65,6 +67,8 @@ const LAUNCH_TIMEOUT: Duration = Duration::from_secs(90);
const SLOW_NETWORK_WAIT: Duration = Duration::from_secs(3); const SLOW_NETWORK_WAIT: Duration = Duration::from_secs(3);
/// How long a file takes in Mode::SlowFiles. /// How long a file takes in Mode::SlowFiles.
const SLOW_FILE: Duration = Duration::from_secs(6); const SLOW_FILE: Duration = Duration::from_secs(6);
/// Where Mode::SignIn redirects to (on `localhost`: another origin than the 127.0.0.1 app).
const SIGN_IN: &str = "/sign-in";
/// Exercise the real browser build's command bridge, using the same Chrome harness as the offline test. /// Exercise the real browser build's command bridge, using the same Chrome harness as the offline test.
pub fn run_api(site: &str) -> Result<(), String> { pub fn run_api(site: &str) -> Result<(), String> {
@@ -347,6 +351,16 @@ pub fn run(root: &Path, site: Option<&str>, required: bool) -> Result<(), String
server.set(Mode::Refuse); server.set(Mode::Refuse);
step("offline: build v6 still starts from the kept copy", launch(&mut cdp, "v6")?); step("offline: build v6 still starts from the kept copy", launch(&mut cdp, "v6")?);
// In a tab of its own: in the tab that was redirected, Chrome skipped the next launch's check
// for a new worker (the development build's step below needs it).
server.set(Mode::SignIn);
let t = Instant::now();
let mut tab = browser.new_tab()?;
tab.call("Page.navigate", json!({ "url": format!("http://127.0.0.1:{}{PREFIX}", server.port) }))?;
wait_for(&mut tab, "location.pathname", "the sign-in page is shown", |p| p == SIGN_IN)?;
tab.call("Page.close", json!({}))?;
step("sign-in in front of the app: the launch shows the sign-in, not the kept v6", t.elapsed());
server.set(Mode::Serve(dev)); server.set(Mode::Serve(dev));
let t = launch(&mut cdp, "dev")?; let t = launch(&mut cdp, "dev")?;
wait_for(&mut cdp, &state_js, "the worker retires", |s| s["registered"] == 0 && s["caches"] == json!({}))?; wait_for(&mut cdp, &state_js, "the worker retires", |s| s["registered"] == 0 && s["caches"] == json!({}))?;
@@ -502,6 +516,9 @@ enum Mode {
SlowFiles(PathBuf), SlowFiles(PathBuf),
/// Like SlowFiles, but `index.html` fails at once. /// Like SlowFiles, but `index.html` fails at once.
FailFast(PathBuf), FailFast(PathBuf),
/// A sign-in in front of the app (an expired session): every request is redirected to
/// SIGN_IN on another origin.
SignIn,
} }
struct Server { struct Server {
@@ -563,16 +580,31 @@ fn answer(mut stream: TcpStream, shared: &Mutex<Mode>, slow: &AtomicUsize, elsew
if reader.read_line(&mut request).is_err() { if reader.read_line(&mut request).is_err() {
return; return;
} }
let mut port = "";
let mut host = String::new();
loop { loop {
let mut header = String::new(); let mut header = String::new();
match reader.read_line(&mut header) { match reader.read_line(&mut header) {
Ok(0) | Err(_) => return, Ok(0) | Err(_) => return,
Ok(_) if header == "\r\n" => break, Ok(_) if header == "\r\n" => break,
Ok(_) => {} Ok(_) => {
if let Some(h) = header.strip_prefix("Host: ") {
host = h.trim().to_string();
}
}
} }
} }
if let Some((_, p)) = host.rsplit_once(':') {
port = p;
}
let path = request.split_whitespace().nth(1).unwrap_or("/"); let path = request.split_whitespace().nth(1).unwrap_or("/");
let path = path.split('?').next().unwrap_or_default(); let path = path.split('?').next().unwrap_or_default();
if path == SIGN_IN {
let body = b"<!DOCTYPE html><html><body>Sign in</body></html>";
let head = format!("HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len());
let _ = stream.write_all(head.as_bytes()).and_then(|()| stream.write_all(body));
return;
}
let (file, other) = match path.strip_prefix(ELSEWHERE) { let (file, other) = match path.strip_prefix(ELSEWHERE) {
Some(f) => (Some(f), true), Some(f) => (Some(f), true),
None => (path.strip_prefix(PREFIX), false), None => (path.strip_prefix(PREFIX), false),
@@ -603,6 +635,11 @@ fn answer(mut stream: TcpStream, shared: &Mutex<Mode>, slow: &AtomicUsize, elsew
(dir, false, false) (dir, false, false)
} }
Mode::Mixed(dir, next) => (if file == Some("index.html") { next } else { dir }, false, false), Mode::Mixed(dir, next) => (if file == Some("index.html") { next } else { dir }, false, false),
Mode::SignIn => {
let head = format!("HTTP/1.1 302 Found\r\nLocation: http://localhost:{port}{SIGN_IN}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
let _ = stream.write_all(head.as_bytes());
return;
}
Mode::Refuse => return, Mode::Refuse => return,
Mode::Hang => return hold(shared, |m| matches!(m, Mode::Hang)), Mode::Hang => return hold(shared, |m| matches!(m, Mode::Hang)),
}; };