blob: f7eadccc03b4606e3efdfc61dc4f4be3a535f9d1 [file]
//! The filesystem surface, which is asynchronous.
//!
//! These need a real `App`, because that is where the executors are: a capability
//! check runs on the calling thread and the syscall behind it does not. The
//! denial cases live next to the resolver in `capability.rs` and `host.rs`,
//! where they need no window at all — a refusal never reaches the disk.
use std::ops::Deref;
use crate::{
Capabilities, RenderSnapshot, ScriptView, ShellRuntime,
spec::{CallbackId, SpecOp},
};
use gpui::{Entity, IntoElement as _, TestAppContext, VisualTestContext};
/// A view that does its filesystem work in a task and records the outcome, so
/// the assertion can be made on what the script saw rather than on what the host
/// did.
const PROBE: &str = r#"
import { div, View } from "gpui-kit";
import { v_flex } from "gpui-base";
import * as fs from "fs/promises";
export default class Probe extends View {
init(_props, cx) {
this.state = "pending";
cx.spawn(async (cx) => {
try {
await fs.writeFile("notes.txt", "hello");
await fs.writeFile("bytes.bin", new Uint8Array([0, 255, 42]));
const back = await fs.readFile("notes.txt", "utf8");
const bytes = await fs.readFile("bytes.bin");
const names = await fs.readdir(".");
const entries = await fs.readdir(".", { withFileTypes: true });
const entryShape = entries.every((entry) =>
typeof entry.name === "string" && typeof entry.isDirectory() === "boolean"
);
const there = await fs.exists("notes.txt");
await fs.mkdir("nested/deeper", { recursive: true });
const nested = await fs.exists("nested/deeper");
await fs.unlink("notes.txt");
const gone = !(await fs.exists("notes.txt"));
this.state = `${back}|${bytes instanceof Uint8Array}:${[...bytes].join(",")}|${names.join(",")}|${entryShape}|${there}|${nested}|${gone}`;
} catch (error) {
this.state = `failed: ${error.message}`;
}
cx.notify();
});
}
render() {
return v_flex().child(this.state);
}
}
"#;
const DENIAL_PROBE: &str = r#"
import { div, View } from "gpui-kit";
import { v_flex } from "gpui-base";
import * as fs from "fs/promises";
export default class Probe extends View {
init(_props, cx) {
this.state = "pending";
cx.spawn(async (cx) => {
try { await fs.readFile("__PATH__"); this.state = "unexpectedly allowed"; }
catch (error) { this.state = `rejected:${error.message}`; }
cx.notify();
});
}
render(cx) { return v_flex().child(this.state); }
}
"#;
#[gpui::test]
fn every_fs_call_settles_through_a_promise(cx: &mut TestAppContext) {
let directory = std::env::temp_dir().join(format!("gpui-shell-fs-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&directory);
std::fs::create_dir_all(&directory).expect("a granted root");
cx.update(|cx| crate::init(cx));
crate::set_capabilities(
Capabilities::new()
.read_roots([directory.clone()])
.write_roots([directory.clone()]),
);
let runtime = ShellRuntime::new_isolated().expect("runtime");
cx.update(|cx| runtime.set_global(cx));
let view_type = runtime.load_source("probe.js", PROBE).expect("load");
let window = cx.add_window(|_, _| Empty);
let mut context = VisualTestContext::from_window(*window.deref(), cx);
let view = context
.update(|window, cx| runtime.instantiate_view(&view_type, window, cx))
.expect("instantiate");
// Nothing has happened yet: the calls returned promises and the work is on
// a background thread. That is the property this whole change is about.
draw(&mut context, &view);
let before = snapshot_text(&mut context, &view);
assert!(
before.contains("pending"),
"the first render should have found the work still in flight, got: {before}"
);
context.run_until_parked();
draw(&mut context, &view);
let after = snapshot_text(&mut context, &view);
assert!(
after.contains("hello|true:0,255,42|bytes.bin,notes.txt|true|true|true|true"),
"the round trip did not settle as expected: {after}"
);
let _ = std::fs::remove_dir_all(&directory);
}
/// A file over the ceiling is refused by name rather than by an out-of-memory
/// somewhere inside the VM.
#[gpui::test]
fn an_oversized_read_is_refused_by_name(cx: &mut TestAppContext) {
let directory = std::env::temp_dir().join(format!("gpui-shell-fs-big-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&directory);
std::fs::create_dir_all(&directory).expect("a granted root");
// Sparse where the filesystem allows it: the test is about the check, not
// about moving sixty-five megabytes.
let big = directory.join("big.bin");
let file = std::fs::File::create(&big).expect("a large file");
file.set_len(65 * 1024 * 1024).expect("a large length");
drop(file);
cx.update(|cx| crate::init(cx));
crate::set_capabilities(Capabilities::new().read_roots([directory.clone()]));
let runtime = ShellRuntime::new_isolated().expect("runtime");
cx.update(|cx| runtime.set_global(cx));
let source = PROBE.replace(
r#"await fs.writeFile("notes.txt", "hello");"#,
r#"await fs.readFile("big.bin");"#,
);
let view_type = runtime.load_source("big.js", &source).expect("load");
let window = cx.add_window(|_, _| Empty);
let mut context = VisualTestContext::from_window(*window.deref(), cx);
let view = context
.update(|window, cx| runtime.instantiate_view(&view_type, window, cx))
.expect("instantiate");
context.run_until_parked();
draw(&mut context, &view);
let rendered = snapshot_text(&mut context, &view);
assert!(
rendered.contains("big.bin") && rendered.contains("limit"),
"an oversized read should name the file and the limit: {rendered}"
);
let _ = std::fs::remove_dir_all(&directory);
}
#[gpui::test]
fn an_oversized_write_is_refused_before_reaching_disk(cx: &mut TestAppContext) {
let directory =
std::env::temp_dir().join(format!("gpui-shell-fs-big-write-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&directory);
std::fs::create_dir_all(&directory).expect("a granted root");
cx.update(crate::init);
crate::set_capabilities(Capabilities::new().write_roots([directory.clone()]));
let runtime = ShellRuntime::new_isolated().expect("runtime");
cx.update(|cx| runtime.set_global(cx));
let source = PROBE.replace(
r#"await fs.writeFile("notes.txt", "hello");"#,
r#"await fs.writeFile("notes.txt", "x".repeat(8 * 1024 * 1024 + 1));"#,
);
let view_type = runtime.load_source("big-write.js", &source).expect("load");
let window = cx.add_window(|_, _| Empty);
let mut context = VisualTestContext::from_window(*window.deref(), cx);
let view = context
.update(|window, cx| runtime.instantiate_view(&view_type, window, cx))
.expect("instantiate");
context.run_until_parked();
draw(&mut context, &view);
let rendered = snapshot_text(&mut context, &view);
assert!(
rendered.contains("write") && rendered.contains("limit"),
"{rendered}"
);
assert!(!directory.join("notes.txt").exists());
let _ = std::fs::remove_dir_all(directory);
}
#[gpui::test]
fn fs_is_denied_by_default_through_the_javascript_module(cx: &mut TestAppContext) {
let rendered = denial_probe(cx, Capabilities::new(), "notes.txt");
assert!(
rendered.contains("rejected:") && rendered.contains("capabilities.fs.read"),
"{rendered}"
);
}
#[cfg(unix)]
#[gpui::test]
fn fs_module_cannot_follow_a_symlink_out_of_a_granted_root(cx: &mut TestAppContext) {
use std::os::unix::fs::symlink;
let base = std::env::temp_dir().join(format!("gpui-shell-fs-link-{}", std::process::id()));
let root = base.join("root");
let outside = base.join("outside");
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&root).expect("granted root");
std::fs::create_dir_all(&outside).expect("outside root");
std::fs::write(outside.join("secret.txt"), "secret").expect("secret");
symlink(&outside, root.join("escape")).expect("escape symlink");
let rendered = denial_probe(
cx,
Capabilities::new().read_roots([root]),
"escape/secret.txt",
);
assert!(rendered.contains("rejected:"), "{rendered}");
assert!(!rendered.contains("unexpectedly allowed"), "{rendered}");
let _ = std::fs::remove_dir_all(&base);
}
const METADATA_PROBE: &str = r#"
import { div, View } from "gpui-kit";
import { v_flex } from "gpui-base";
import * as fs from "fs/promises";
export default class Probe extends View {
init(_props, cx) {
this.state = "pending";
cx.spawn(async (cx) => {
try {
await fs.writeFile("draft.txt", "hello");
const file = await fs.stat("draft.txt");
const root = await fs.stat(".");
const fresh = Date.now() - file.mtimeMs < 60000 && file.mtime instanceof Date;
await fs.rename("draft.txt", "final.txt");
const moved = !(await fs.exists("draft.txt")) && await fs.readFile("final.txt", "utf8");
const link = await fs.lstat("link.txt");
const target = await fs.readlink("link.txt");
const followed = await fs.stat("link.txt");
this.state = [
`${file.isFile()}:${file.isDirectory()}:${file.size}:${fresh}`,
`${root.isDirectory()}`,
moved,
`${link.isSymbolicLink()}:${target}:${followed.isFile()}:${followed.isSymbolicLink()}`,
].join("|");
} catch (error) {
this.state = `failed: ${error.message}`;
}
cx.notify();
});
}
render() { return v_flex().child(this.state); }
}
"#;
#[cfg(unix)]
#[gpui::test]
fn stat_rename_and_readlink_settle_through_promises(cx: &mut TestAppContext) {
let directory =
std::env::temp_dir().join(format!("gpui-shell-fs-metadata-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&directory);
std::fs::create_dir_all(&directory).expect("a granted root");
std::fs::write(directory.join("target.txt"), "linked").expect("link target");
std::os::unix::fs::symlink("target.txt", directory.join("link.txt")).expect("symlink");
let rendered = metadata_probe(
cx,
Capabilities::new()
.read_roots([directory.clone()])
.write_roots([directory.clone()]),
METADATA_PROBE,
);
assert!(
rendered.contains("true:false:5:true|true|hello|true:target.txt:true:false"),
"{rendered}"
);
let _ = std::fs::remove_dir_all(&directory);
}
// Only Unix tests use the link probes: creating a symlink needs privileges on Windows.
#[cfg(unix)]
const ABSOLUTE_LINK_PROBE: &str = r#"
import { div, View } from "gpui-kit";
import { v_flex } from "gpui-base";
import * as fs from "fs/promises";
export default class Probe extends View {
init(_props, cx) {
this.state = "pending";
cx.spawn(async (cx) => {
const root = "__ROOT__";
const outcome = async (body) => {
try {
return String(await body());
} catch (error) {
return `rejected(${error.message})`;
}
};
// Which layer said no: the capability gate names the manifest key to
// add, an error from the filesystem does not.
const refusal = async (body) => {
try {
await body();
return "allowed";
} catch (error) {
return error.message.includes("capabilities.fs") ? "denied" : "failed";
}
};
const link = `${root}/link.txt`;
this.state = [
await outcome(async () => (await fs.lstat(link)).isSymbolicLink()),
await outcome(() => fs.readlink(link)),
await outcome(() => fs.readFile(`${root}/absolute-link.txt`, "utf8")),
await outcome(async () => {
await fs.rename(`${root}/to-move.txt`, `${root}/moved.txt`);
return (await fs.lstat(`${root}/moved.txt`)).isSymbolicLink();
}),
await outcome(async () => {
await fs.rename(`${root}/replacement.txt`, `${root}/replaced-link.txt`);
const entry = await fs.lstat(`${root}/replaced-link.txt`);
const text = await fs.readFile(`${root}/replaced-link.txt`, "utf8");
return `${entry.isSymbolicLink()}:${text}`;
}),
await refusal(() => fs.rmdir(`${root}/empty-link`)),
await outcome(() => fs.unlink(link)),
].join("|");
cx.notify();
});
}
render() { return v_flex().child(this.state); }
}
"#;
/// An absolute path names a symlink the same way a relative one does: `lstat`,
/// `readlink`, `rename`, `rmdir` and `unlink` act on the link itself, not on
/// what it points to. Renaming onto a link replaces the link and leaves its
/// former target alone; `rmdir` on a link to an empty directory is past the
/// gate and fails below it as a non-directory, rather than removing the
/// directory. Reading through a link
/// whose stored target is absolute keeps working.
#[cfg(unix)]
#[gpui::test]
fn link_operations_on_an_absolute_path_act_on_the_link(cx: &mut TestAppContext) {
let directory = std::env::temp_dir().join(format!(
"gpui-shell-fs-absolute-link-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&directory);
std::fs::create_dir_all(&directory).expect("a granted root");
std::fs::write(directory.join("target.txt"), "linked").expect("link target");
std::os::unix::fs::symlink("target.txt", directory.join("link.txt")).expect("symlink");
std::os::unix::fs::symlink("target.txt", directory.join("to-move.txt")).expect("symlink");
std::os::unix::fs::symlink(
directory.join("target.txt"),
directory.join("absolute-link.txt"),
)
.expect("symlink with an absolute target");
std::fs::write(directory.join("replacement.txt"), "replacement").expect("rename source");
std::fs::write(directory.join("former-target.txt"), "former").expect("former target");
std::os::unix::fs::symlink(
directory.join("former-target.txt"),
directory.join("replaced-link.txt"),
)
.expect("symlink at the rename destination");
std::fs::create_dir(directory.join("empty")).expect("an empty directory");
std::os::unix::fs::symlink(directory.join("empty"), directory.join("empty-link"))
.expect("symlink to an empty directory");
let source = ABSOLUTE_LINK_PROBE.replace("__ROOT__", &directory.to_string_lossy());
let rendered = metadata_probe(
cx,
Capabilities::new()
.read_roots([directory.clone()])
.write_roots([directory.clone()]),
&source,
);
let target_survived = directory.join("target.txt").is_file();
let link_removed = std::fs::symlink_metadata(directory.join("link.txt")).is_err();
let former_target = std::fs::read_to_string(directory.join("former-target.txt")).ok();
let empty_link_kept = std::fs::symlink_metadata(directory.join("empty-link"))
.is_ok_and(|metadata| metadata.is_symlink())
&& directory.join("empty").is_dir();
let _ = std::fs::remove_dir_all(&directory);
// One assertion reporting everything, so a failure shows every symptom at once.
assert!(
rendered.contains("true|target.txt|linked|true|false:replacement|failed|undefined")
&& target_survived
&& link_removed
&& former_target.as_deref() == Some("former")
&& empty_link_kept,
"probe: {rendered}; target survived: {target_survived}; link removed: {link_removed}; \
former target: {former_target:?}; empty link kept: {empty_link_kept}"
);
}
#[cfg(unix)]
const OUTSIDE_LINK_PROBE: &str = r#"
import { div, View } from "gpui-kit";
import { v_flex } from "gpui-base";
import * as fs from "fs/promises";
export default class Probe extends View {
init(_props, cx) {
this.state = "pending";
cx.spawn(async (cx) => {
const root = "__ROOT__";
const outcome = async (body) => {
try {
return String(await body());
} catch (error) {
return `rejected(${error.message})`;
}
};
// Which layer said no: the capability gate names the manifest key to
// add, an error from the filesystem does not.
const refusal = async (body) => {
try {
await body();
return "allowed";
} catch (error) {
return error.message.includes("capabilities.fs") ? "denied" : "failed";
}
};
const link = `${root}/outside-link.txt`;
const moved = `${root}/moved-link.txt`;
this.state = [
await outcome(async () => (await fs.lstat(link)).isSymbolicLink()),
await outcome(() => fs.readlink(link)),
await refusal(() => fs.stat(link)),
await refusal(() => fs.readFile(link, "utf8")),
await outcome(async () => {
await fs.rename(link, moved);
return (await fs.lstat(moved)).isSymbolicLink();
}),
await outcome(() => fs.unlink(moved)),
].join("|");
cx.notify();
});
}
render() { return v_flex().child(this.state); }
}
"#;
/// A link inside the root whose target is a real file outside it: the link
/// entry is the application's to describe, move and remove, while `stat` and
/// `readFile`, which follow it, are still denied at the capability gate.
#[cfg(unix)]
#[gpui::test]
fn link_operations_reach_a_link_whose_target_is_outside_the_root(cx: &mut TestAppContext) {
let base =
std::env::temp_dir().join(format!("gpui-shell-fs-outside-link-{}", std::process::id()));
let root = base.join("root");
let outside = base.join("outside");
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&root).expect("a granted root");
std::fs::create_dir_all(&outside).expect("outside the root");
std::fs::write(outside.join("secret.txt"), "secret").expect("outside target");
std::os::unix::fs::symlink(outside.join("secret.txt"), root.join("outside-link.txt"))
.expect("symlink out of the root");
let source = OUTSIDE_LINK_PROBE.replace("__ROOT__", &root.to_string_lossy());
let rendered = metadata_probe(
cx,
Capabilities::new()
.read_roots([root.clone()])
.write_roots([root.clone()]),
&source,
);
let expected = format!(
"true|{}|denied|denied|true|undefined",
outside.join("secret.txt").display()
);
let secret = std::fs::read_to_string(outside.join("secret.txt")).ok();
let links_gone = std::fs::symlink_metadata(root.join("outside-link.txt")).is_err()
&& std::fs::symlink_metadata(root.join("moved-link.txt")).is_err();
let _ = std::fs::remove_dir_all(&base);
assert!(
rendered.contains(&expected) && secret.as_deref() == Some("secret") && links_gone,
"probe: {rendered}; expected: {expected}; secret: {secret:?}; links gone: {links_gone}"
);
}
/// `rename` needs write access at both ends, so it cannot carry a file out of
/// a root the application may only read.
#[gpui::test]
fn rename_needs_write_access_at_both_ends(cx: &mut TestAppContext) {
let base = std::env::temp_dir().join(format!("gpui-shell-fs-rename-{}", std::process::id()));
let readable = base.join("readable");
let writable = base.join("writable");
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&readable).expect("read root");
std::fs::create_dir_all(&writable).expect("write root");
std::fs::write(readable.join("kept.txt"), "kept").expect("source file");
let source = METADATA_PROBE.replace(
r#"await fs.writeFile("draft.txt", "hello");"#,
&format!(
r#"await fs.rename({:?}, {:?});"#,
readable.join("kept.txt"),
writable.join("taken.txt")
),
);
let rendered = metadata_probe(
cx,
Capabilities::new()
.read_roots([readable.clone(), writable.clone()])
.write_roots([writable.clone()]),
&source,
);
assert!(
rendered.contains("failed:") && rendered.contains("capabilities.fs.write"),
"{rendered}"
);
assert!(readable.join("kept.txt").exists());
assert!(!writable.join("taken.txt").exists());
let _ = std::fs::remove_dir_all(&base);
}
fn metadata_probe(cx: &mut TestAppContext, capabilities: Capabilities, source: &str) -> String {
cx.update(crate::init);
crate::set_capabilities(capabilities);
let runtime = ShellRuntime::new_isolated().expect("runtime");
cx.update(|cx| runtime.set_global(cx));
let view_type = runtime.load_source("fs-metadata.js", source).expect("load");
let window = cx.add_window(|_, _| Empty);
let mut context = VisualTestContext::from_window(*window.deref(), cx);
let view = context
.update(|window, cx| runtime.instantiate_view(&view_type, window, cx))
.expect("instantiate");
context.run_until_parked();
draw(&mut context, &view);
snapshot_text(&mut context, &view)
}
fn denial_probe(cx: &mut TestAppContext, capabilities: Capabilities, path: &str) -> String {
cx.update(crate::init);
crate::set_capabilities(capabilities);
let runtime = ShellRuntime::new_isolated().expect("runtime");
cx.update(|cx| runtime.set_global(cx));
let source = DENIAL_PROBE.replace("__PATH__", path);
let view_type = runtime.load_source("fs-denial.js", &source).expect("load");
let window = cx.add_window(|_, _| Empty);
let mut context = VisualTestContext::from_window(*window.deref(), cx);
let view = context
.update(|window, cx| runtime.instantiate_view(&view_type, window, cx))
.expect("instantiate");
context.run_until_parked();
draw(&mut context, &view);
snapshot_text(&mut context, &view)
}
struct Empty;
impl gpui::Render for Empty {
fn render(
&mut self,
_: &mut gpui::Window,
_: &mut gpui::Context<Self>,
) -> impl gpui::IntoElement {
gpui::div()
}
}
/// The store answers from memory and reaches the disk on its own.
///
/// `set` stays synchronous — a setting a script can read during `render` without
/// awaiting is the whole point of the cache — and the write it makes necessary
/// happens on a background thread. `flush` is for a script that has to know the
/// write landed.
const STORE_PROBE: &str = r#"
import { div, View } from "gpui-kit";
import { v_flex } from "gpui-base";
export default class Probe extends View {
init(_props, cx) {
// Synchronous, against the cache. A burst of these is one file, not four.
// Values are strings, so anything with structure goes through JSON — the
// browser's bargain, and the reason `setItem` takes what it is given.
localStorage.setItem("window", JSON.stringify({ title: "Notes", size: [640, 480] }));
localStorage.setItem("theme", "dark");
localStorage.setItem("scratch", "1");
localStorage.removeItem("scratch");
// Readable immediately, with nothing awaited.
const keys = [];
for (let i = 0; i < localStorage.length; i += 1) keys.push(localStorage.key(i));
this.state = `${JSON.parse(localStorage.getItem("window")).title}|${keys.join(",")}`;
// A missing entry is `null`, as on the web, so `=== null` finds it.
const missing = [
localStorage.getItem("absent"),
localStorage.key(99),
sessionStorage.getItem("absent"),
];
this.state += missing.every((value) => value === null) ? "|null" : "|not-null";
// Memory only, and gone with the process: it never reaches the file this
// test is about, which is the whole distinction between the two.
sessionStorage.setItem("scratch", "kept for this run");
cx.spawn(async (cx) => {
await localStorage.flush();
this.state += "|flushed";
cx.notify();
});
}
render(cx) {
return v_flex().child(this.state);
}
}
"#;
#[gpui::test]
fn the_store_answers_from_memory_and_persists_off_thread(cx: &mut TestAppContext) {
let directory = std::env::temp_dir().join(format!("gpui-shell-store-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&directory);
std::fs::create_dir_all(&directory).expect("a directory for the store");
let file = directory.join("store.json");
cx.update(|cx| crate::init(cx));
crate::set_capabilities(Capabilities::new().storage(true));
crate::set_storage_path(file.clone());
let runtime = ShellRuntime::new_isolated().expect("runtime");
cx.update(|cx| runtime.set_global(cx));
let view_type = runtime.load_source("store.js", STORE_PROBE).expect("load");
let window = cx.add_window(|_, _| Empty);
let mut context = VisualTestContext::from_window(*window.deref(), cx);
let view = context
.update(|window, cx| runtime.instantiate_view(&view_type, window, cx))
.expect("instantiate");
// The cache answered during `init`, before anything reached the disk.
draw(&mut context, &view);
let immediate = snapshot_text(&mut context, &view);
assert!(
immediate.contains("Notes|window,theme"),
"the store should answer from memory without awaiting: {immediate}"
);
assert!(
immediate.contains("|null"),
"a missing entry should read as null: {immediate}"
);
context.run_until_parked();
draw(&mut context, &view);
let settled = snapshot_text(&mut context, &view);
assert!(
settled.contains("flushed"),
"flush never resolved: {settled}"
);
// It reached the disk, atomically, and the removed key did not. The value
// is the JSON the script serialized, stored as the string it is — a
// `localStorage` file holds strings, so the braces here are the script's
// own text rather than structure the store understood.
let written = std::fs::read_to_string(&file).expect("the storage file exists");
assert!(written.contains("Notes"), "{written}");
assert!(written.contains("\"theme\": \"dark\""), "{written}");
assert!(
!written.contains("scratch"),
"the removed key must not land, and neither must the session one: {written}"
);
assert!(
!directory.join("store.json.tmp").exists(),
"the temporary file should have been renamed away"
);
let _ = std::fs::remove_dir_all(&directory);
}
const STORE_RETRY_PROBE: &str = r#"
import { div, View } from "gpui-kit";
import { v_flex, Checkbox } from "gpui-base";
export default class Probe extends View {
init() {
this.state = "failed write pending";
localStorage.setItem("attempt", "1");
}
render(cx) {
return v_flex()
.child(this.state)
.child(Checkbox.new("retry").on_change((_checked, cx) => {
cx.spawn(async (cx) => {
try {
await localStorage.flush();
this.state = "flushed";
} catch (error) {
this.state = `rejected:${error.message}`;
}
cx.notify();
});
}));
}
}
"#;
/// A failed automatic write must park rather than drive the same revision in a
/// tight loop. A later explicit durability request is allowed to retry it.
#[gpui::test]
fn a_failed_store_write_parks_until_flush_explicitly_retries_it(cx: &mut TestAppContext) {
let directory =
std::env::temp_dir().join(format!("gpui-shell-store-retry-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&directory);
std::fs::create_dir_all(&directory).expect("a directory for the store test");
let file = directory.join("store.json");
cx.update(crate::init);
crate::set_capabilities(Capabilities::new().storage(true));
crate::set_storage_path(file.clone());
// The store's startup read has already observed a normal first run. Change
// the target afterwards so only the asynchronous persistence step fails.
std::fs::create_dir(&file).expect("a directory that makes the first rename fail");
let runtime = ShellRuntime::new_isolated().expect("runtime");
cx.update(|cx| runtime.set_global(cx));
let view_type = runtime
.load_source("store-retry.js", STORE_RETRY_PROBE)
.expect("load");
let window = cx.add_window(|_, _| Empty);
let mut context = VisualTestContext::from_window(*window.deref(), cx);
let view = context
.update(|window, cx| runtime.instantiate_view(&view_type, window, cx))
.expect("instantiate");
draw(&mut context, &view);
context.run_until_parked();
assert!(
file.is_dir(),
"the failed revision should park without replacing its target"
);
std::fs::remove_dir(&file).expect("make the store path writable");
let callback = first_change_callback(&mut context, &view);
context.update(|window, cx| runtime.dispatch_change(callback, true, window, cx));
context.run_until_parked();
draw(&mut context, &view);
let rendered = snapshot_text(&mut context, &view);
assert!(
rendered.contains("flushed"),
"flush did not retry: {rendered}"
);
let written = std::fs::read_to_string(&file).expect("the retried storage write landed");
assert!(written.contains("\"attempt\": \"1\""), "{written}");
let _ = std::fs::remove_dir_all(&directory);
}
fn draw(context: &mut VisualTestContext, view: &Entity<ScriptView>) {
let view = view.clone();
context.draw(
gpui::Point::default(),
gpui::size(gpui::px(400.), gpui::px(300.)),
move |_, _| view.into_any_element(),
);
}
fn snapshot_text(context: &mut VisualTestContext, view: &Entity<ScriptView>) -> String {
context.update(|_, cx| {
view.read(cx)
.snapshot()
.map(crate::RenderSnapshot::debug_tree)
.unwrap_or_default()
})
}
fn first_change_callback(context: &mut VisualTestContext, view: &Entity<ScriptView>) -> CallbackId {
context.update(|_, cx| {
view.read(cx)
.snapshot()
.and_then(first_change_callback_in_snapshot)
.expect("the retry control should have a change handler")
})
}
fn first_change_callback_in_snapshot(snapshot: &RenderSnapshot) -> Option<CallbackId> {
(0..snapshot.len() as u32)
.filter_map(|id| snapshot.arena().node(id))
.flat_map(|node| node.ops())
.find_map(|op| match op {
SpecOp::Callback("on_change", id) => Some(*id),
_ => None,
})
}