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
188 changes: 97 additions & 91 deletions app/App.zig
Original file line number Diff line number Diff line change
Expand Up @@ -75,15 +75,20 @@ config_folder: []const u8,

palette_folder: []const u8,

/// Each registered plugin's data folder (`pluginDataFolder`), by lowercased id, filled on the
/// main thread as plugins register (`pluginRegistered`). Key and path owned, and kept after the
/// plugin goes, so a path handed out stays good for the app's life. Guarded by
/// `plugin_data_lock`: a plugin may ask from a worker thread (an indexer, an LSP client).
/// Each registered plugin's data folder (`pluginDataFolder`), by lowercased id, one entry per
/// folder: `Host.registerPlugin` refuses ids differing only in case, so a retired `Graphl` and a
/// later `graphl` are one folder with one entry. Filled on the main thread as plugins register
/// (`pluginRegistered`). Key and path owned, and kept after the plugin is
/// retired, so a path handed out stays good for the app's life. Guarded by `plugin_data_lock`: a
/// plugin may ask from a worker thread (an indexer, an LSP client).
plugin_data_folders: std.StringHashMapUnmanaged(PluginDataFolder) = .empty,
plugin_data_lock: std.Io.Mutex = .init,
/// Registered plugins whose entry could not be made when they registered (out of memory), by the
/// plugin's own id (its memory, good while it is registered): made on their next ask instead.
plugin_data_missed: [8]?[]const u8 = @splat(null),
/// Registered plugins whose entry could not be made when they registered (out of memory), by
/// lowercased id, owned: only these get one made on their next ask. An unknown id never does.
plugin_data_pending: std.ArrayListUnmanaged([]u8) = .empty,
/// Ids that asked for a data folder they cannot have (not a plain identifier), owned: each is
/// said once.
plugin_data_refused: std.ArrayListUnmanaged([]u8) = .empty,

/// Plugin registry + service locator exposed to plugins
host: Host,
Expand Down Expand Up @@ -768,34 +773,20 @@ pub fn retireFolder(app: *App) void {
pub fn releaseRetiredFolders(app: *App) void {
for (app.folder_retired.items) |folder| app.gpa.free(folder);
app.folder_retired.clearRetainingCapacity();
// Plugins unregistered last frame have run their `deinit` by now: their data folders stop
// being handed out (`pluginUnregistered`).
app.lockPluginData();
defer app.unlockPluginData();
var it = app.plugin_data_folders.valueIterator();
while (it.next()) |f| if (f.leaving) {
f.leaving = false;
f.registered = false;
};
}

pub const PluginDataFolder = struct {
/// Null for an id that names no folder (`isValidPluginId`: `fizzy.store` has a dot in it).
path: ?[]const u8,
/// Its plugin is registered now. A path stays in the table after its plugin goes, since a
/// caller may hold it, but is not handed out again until the plugin is back.
path: []const u8,
/// Its plugin is registered now. A path stays in the table after its plugin is retired, since
/// a caller may hold it, but is not handed out again until the plugin is back.
registered: bool = true,
/// Its plugin was unregistered this frame. Still handed out, since its `deinit` (which runs
/// after) may write there; `releaseRetiredFolders`, at the next frame's top, makes it gone.
leaving: bool = false,
/// When the folder was last found or made, on `std.Io.Clock.awake`: an ask within
/// `plugin_data_recheck_ns` of it trusts it is there.
made_at: ?i96 = null,
/// When making it last failed (a read-only or full disk): asks within `plugin_data_retry_ns`
/// of it get null without trying.
failed_at: ?i96 = null,
/// Something was said about this folder (an id that names none, a run of failures), so it
/// is not said again until things change.
/// A run of failures was said, so it is not said again until the folder is made.
said: bool = false,
};

Expand All @@ -804,13 +795,6 @@ pub const plugin_data_recheck_ns: i96 = std.time.ns_per_s;
/// How long a plugin data folder that could not be made is left before it is tried again.
pub const plugin_data_retry_ns: i96 = 5 * std.time.ns_per_s;

/// The key a plugin's data folder is kept under: its id in lowercase, in `buf`. Null for an id
/// longer than any valid one, which then has no folder and no entry.
fn pluginDataKey(buf: *[64]u8, id: []const u8) ?[]const u8 {
if (id.len > buf.len) return null;
return std.ascii.lowerString(buf, id);
}

/// `plugin_data_lock`, which the web, single-threaded and with `std.Io`'s mutex unavailable to
/// it, goes without.
fn lockPluginData(app: *App) void {
Expand All @@ -823,65 +807,75 @@ fn unlockPluginData(app: *App) void {
app.plugin_data_lock.unlock(dvui.io);
}

/// The key a plugin's entry is kept under: its id in lowercase, in `buf`. Null for an id that is
/// not a plain identifier (`isValidPluginId`: `fizzy.store` has a dot in it), which has no folder.
fn pluginDataKey(buf: *[64]u8, id: []const u8) ?[]const u8 {
if (!isValidPluginId(id)) return null;
return std.ascii.lowerString(buf, id);
}

/// A plugin registered (`Host.registerPlugin`, through `EditorAPI.pluginRegistered`), on the main
/// thread: its data folder's entry is made ready here, so `pluginDataFolder`, asked from any
/// thread, never walks the host's plugin list. Out of memory, it is remembered and made on the
/// plugin's next ask.
/// thread, never walks the host's plugin list. Out of memory, the id is kept to make it on its
/// first ask.
pub fn pluginRegistered(app: *App, id: []const u8) void {
var buf: [64]u8 = undefined;
const name = pluginDataKey(&buf, id) orelse return;
const key = pluginDataKey(&buf, id) orelse return;
app.lockPluginData();
defer app.unlockPluginData();
if (app.plugin_data_folders.getPtr(name)) |f| {
if (app.plugin_data_folders.getPtr(key)) |f| {
f.registered = true;
f.leaving = false;
return;
}
if (app.addPluginDataEntry(name, id) != null) return;
if (app.addPluginDataEntry(key) != null) return;
std.log.warn("plugin '{s}' data folder: out of memory; tried again on its first ask", .{id});
for (&app.plugin_data_missed) |*slot| if (slot.* == null) {
slot.* = id;
return;
};
const kept = app.gpa.dupe(u8, key) catch return;
app.plugin_data_pending.append(app.gpa, kept) catch app.gpa.free(kept);
}

/// A plugin was unregistered (`Host.unregisterPlugin`, through `EditorAPI.pluginUnregistered`),
/// before its `deinit` runs: its folder is still handed out for that, and stops being at the next
/// frame's top (`releaseRetiredFolders`). The entry stays, since a worker may still hold the path.
pub fn pluginUnregistered(app: *App, id: []const u8) void {
/// A plugin was retired (`Host.retirePlugin`, after its `deinit`, or `Host.unregisterPlugin`,
/// through `EditorAPI.pluginRetired`): its folder is not handed out until it is back. The entry
/// stays, since a worker may still hold the path.
pub fn pluginRetired(app: *App, id: []const u8) void {
var buf: [64]u8 = undefined;
const name = pluginDataKey(&buf, id) orelse return;
const key = pluginDataKey(&buf, id) orelse return;
app.lockPluginData();
defer app.unlockPluginData();
for (&app.plugin_data_missed) |*slot| if (slot.*) |missed| {
if (std.mem.eql(u8, missed, id)) slot.* = null;
};
if (app.plugin_data_folders.getPtr(name)) |f| f.leaving = true;
if (app.plugin_data_folders.getPtr(key)) |f| f.registered = false;
_ = app.takePending(key);
}

/// The entry for plugin `id` under `name` (`pluginDataKey`), made now. Null out of memory. Under
/// Whether `key` is waiting for its entry (`plugin_data_pending`), which it no longer is. Under
/// `plugin_data_lock`.
fn addPluginDataEntry(app: *App, name: []const u8, id: []const u8) ?*PluginDataFolder {
const key = app.gpa.dupe(u8, name) catch return null;
const path: ?[]const u8 = if (isValidPluginId(id))
std.fs.path.join(app.gpa, &.{ app.config_folder, "plugin-data", name }) catch {
app.gpa.free(key);
return null;
}
else
null;
const entry = app.plugin_data_folders.getOrPut(app.gpa, key) catch {
app.gpa.free(key);
if (path) |p| app.gpa.free(p);
fn takePending(app: *App, key: []const u8) bool {
for (app.plugin_data_pending.items, 0..) |p, i| if (std.mem.eql(u8, p, key)) {
app.gpa.free(app.plugin_data_pending.swapRemove(i));
return true;
};
return false;
}

/// The entry for `key` (`pluginDataKey`), made now: its folder `<config>/plugin-data/<key>`. Null
/// out of memory. Under `plugin_data_lock`.
fn addPluginDataEntry(app: *App, key: []const u8) ?*PluginDataFolder {
const owned = app.gpa.dupe(u8, key) catch return null;
const path = std.fs.path.join(app.gpa, &.{ app.config_folder, "plugin-data", key }) catch {
app.gpa.free(owned);
return null;
};
const entry = app.plugin_data_folders.getOrPut(app.gpa, owned) catch {
app.gpa.free(owned);
app.gpa.free(path);
return null;
};
entry.value_ptr.* = .{ .path = path };
return entry.value_ptr;
}

/// `<config>/plugin-data/<id>` for the registered plugin `id` (`Host.pluginDataFolder`), the id
/// in lowercase, which ids are unique in (`Host.registerPlugin`), so the folder is the same on a
/// case-sensitive filesystem as on macOS and Windows. The path is borrowed for the app's life.
/// `<config>/plugin-data/<id in lowercase>` for the registered plugin `id` (`Host.pluginDataFolder`,
/// which passes the caller's own plugin's id). `Host.registerPlugin` refuses an id that differs
/// only in case from a registered one, so the folder is this plugin's alone on every filesystem.
/// The path is borrowed for the app's life.
///
/// Made on the first ask, and made sure of again on an ask a second or more after the last: a
/// folder deleted while the app runs comes back then, and a caller whose write into a kept path
Expand All @@ -890,49 +884,48 @@ fn addPluginDataEntry(app: *App, name: []const u8, id: []const u8) ?*PluginDataF
///
/// Separate from `plugins/<id>`, which an update replaces and an uninstall deletes. On the web
/// nothing is made, as there are no directories: the path is the prefix `core.fs` files under in
/// the page's storage, as `palette_folder` is. Null for an id no registered plugin has, for one
/// that is not a plain identifier (said once), or when the folder can't be made: said once for
/// each run of failures, and tried again on an ask `plugin_data_retry_ns` after the last.
/// the page's storage, as `palette_folder` is. Null for an id no registered plugin has, a retired
/// plugin's, an id that is not a plain identifier (said once), or when the folder can't be made:
/// said once for each run of failures, and tried again on an ask `plugin_data_retry_ns` after the
/// last.
pub fn pluginDataFolder(app: *App, id: []const u8) ?[]const u8 {
var buf: [64]u8 = undefined;
const name = pluginDataKey(&buf, id) orelse return null;
const is_wasm = comptime builtin.target.cpu.arch == .wasm32;
var buf: [64]u8 = undefined;
const path = path: {
app.lockPluginData();
defer app.unlockPluginData();
const f = app.plugin_data_folders.getPtr(name) orelse missed: {
// Registered while memory ran out (`pluginRegistered`): made now.
for (&app.plugin_data_missed) |*slot| if (slot.*) |missed| {
if (!std.ascii.eqlIgnoreCase(missed, id)) continue;
const made = app.addPluginDataEntry(name, missed) orelse return null;
slot.* = null;
break :missed made;
};
const key = pluginDataKey(&buf, id) orelse {
app.sayNoFolder(id);
return null;
};
if (!f.registered) return null;
const path = f.path orelse {
if (!f.said) std.log.warn("plugin '{s}' has no data folder: its id is not a plain identifier (letters, digits, '-', '_')", .{id});
f.said = true;
return null;
const f = app.plugin_data_folders.getPtr(key) orelse made: {
// Registered while memory ran out (`pluginRegistered`): made now. Anything else
// asking is no registered plugin.
if (!app.takePending(key)) return null;
break :made app.addPluginDataEntry(key) orelse {
const kept = app.gpa.dupe(u8, key) catch return null;
app.plugin_data_pending.append(app.gpa, kept) catch app.gpa.free(kept);
return null;
};
};
if (is_wasm) return path;
if (!f.registered) return null;
if (is_wasm) return f.path;
const now = std.Io.Clock.awake.now(dvui.io).nanoseconds;
if (f.failed_at) |at| if (now - at < plugin_data_retry_ns) return null;
if (f.made_at) |at| if (now - at < plugin_data_recheck_ns) return path;
break :path path;
if (f.made_at) |at| if (now - at < plugin_data_recheck_ns) return f.path;
break :path f.path;
};

const made = std.Io.Dir.cwd().createDirPath(dvui.io, path);
const now = std.Io.Clock.awake.now(dvui.io).nanoseconds;
app.lockPluginData();
defer app.unlockPluginData();
const f = app.plugin_data_folders.getPtr(name) orelse return null;
const f = app.plugin_data_folders.getPtr(pluginDataKey(&buf, id).?) orelse return null;
if (made) |_| {
f.made_at = now;
f.failed_at = null;
f.said = false;
// Its plugin may have gone while the folder was made.
// Its plugin may have been retired while the folder was made.
return if (f.registered) path else null;
} else |err| {
// Said once for a run of failures: an ask every frame would say it every frame.
Expand All @@ -944,16 +937,29 @@ pub fn pluginDataFolder(app: *App, id: []const u8) ?[]const u8 {
}
}

/// Say once, per id, that `id` has no data folder: it is not a plain identifier. Under
/// `plugin_data_lock`.
fn sayNoFolder(app: *App, id: []const u8) void {
for (app.plugin_data_refused.items) |r| if (std.mem.eql(u8, r, id)) return;
std.log.warn("plugin '{s}' has no data folder: its id is not a plain identifier (letters, digits, '-', '_')", .{id});
const kept = app.gpa.dupe(u8, id) catch return;
app.plugin_data_refused.append(app.gpa, kept) catch app.gpa.free(kept);
}

/// What the app itself owns and frees: the plugin data folders and the per-plugin id lists.
/// Fizzy's `Editor.deinit` calls it; an app running `App` without the editor calls it at its own
/// teardown.
pub fn deinit(app: *App) void {
var it = app.plugin_data_folders.iterator();
while (it.next()) |e| {
app.gpa.free(e.key_ptr.*);
if (e.value_ptr.path) |p| app.gpa.free(p);
app.gpa.free(e.value_ptr.path);
}
app.plugin_data_folders.deinit(app.gpa);
for (app.plugin_data_pending.items) |p| app.gpa.free(p);
app.plugin_data_pending.deinit(app.gpa);
for (app.plugin_data_refused.items) |r| app.gpa.free(r);
app.plugin_data_refused.deinit(app.gpa);
for (app.disabled_plugin_ids.items) |id| app.gpa.free(id);
app.disabled_plugin_ids.deinit(app.gpa);
for (app.undecided_plugin_ids.items) |id| app.gpa.free(id);
Expand Down
14 changes: 9 additions & 5 deletions docs/PLUGINS.md
Original file line number Diff line number Diff line change
Expand Up @@ -884,16 +884,20 @@ during `register` and read back via `@ptrCast` in your vtable functions. There i
What a plugin keeps **between runs** that is not a setting — its recents, an index, a cache —
goes in **`host.pluginDataFolder(&plugin)`**: `<config>/plugin-data/<id>`, the id in lowercase.
Plugin ids are unique ignoring case (`host.registerPlugin` refuses `Graphl` beside `graphl`, and
a loader asks `host.pluginByIdIgnoringCase` before it opens a build), so the folder is the same
on every filesystem. It follows the
app's config folder, so a `FIZZY_PROFILE` sandbox gets its own, and it is not `plugins/<id>`, the
folder the plugin is installed in, which an update replaces and an uninstall deletes. It is
always local, whether or not the open folder is on a mount (§3.12).
a loader asks `host.pluginByIdIgnoringCase` before it opens a build), so the folder is the
plugin's alone on every filesystem. It follows the app's config folder, so a `FIZZY_PROFILE`
sandbox gets its own, and it is not `plugins/<id>`, the folder the plugin is installed in, which
an update replaces and an uninstall deletes. It is always local, whether or not the open folder
is on a mount (§3.12).

- The path is borrowed for the life of the app. The folder is made on the first call and made
sure of again on a call a second or more after the last, so a plugin whose write into a kept
path fails with `FileNotFound` calls again (a second later, if it called just now). Safe from
any thread (an indexer, an LSP client): the app sets each plugin's entry up as it registers.
- It is yours from `register` through your `deinit`: an app unloads a plugin with
`host.retirePlugin` (your registrations go, then your `deinit`, then the app is told), so
writing your state out in `deinit` works, and from then on the path is not handed out until
you are back. `host.unregisterPlugin` alone releases it at once.
- Null when no app is installed (a unit test's bare `Host`), when the plugin is not registered,
when its id is not a plain identifier (letters, digits, `-`, `_`), or when the folder could not
be made: logged once, and tried again on a call a few seconds later.
Expand Down
7 changes: 3 additions & 4 deletions sdk/src/EditorAPI.zig
Original file line number Diff line number Diff line change
Expand Up @@ -159,10 +159,9 @@ pub const VTable = struct {
/// plugin is made ready here, so asks from other threads never walk the host's plugin list.
/// Null where the app keeps nothing per plugin.
pluginRegistered: ?*const fn (ctx: *anyopaque, id: []const u8) void = null,
/// A plugin was unregistered (`Host.unregisterPlugin`), before its `deinit` runs: what the app
/// keeps for it must still serve that `deinit`, and goes once it has run. Null where the app
/// keeps nothing per plugin.
pluginUnregistered: ?*const fn (ctx: *anyopaque, id: []const u8) void = null,
/// A plugin was taken out for good (`Host.retirePlugin`): unregistered, and its `deinit` has
/// run, so what the app kept for it goes now. Null where the app keeps nothing per plugin.
pluginRetired: ?*const fn (ctx: *anyopaque, id: []const u8) void = null,
/// Mark fizzy's settings dirty so the debounced autosave persists them.
markSettingsDirty: *const fn (ctx: *anyopaque) void,
/// A credential kept for a plugin, by key (`<plugin>.<what>`), outside `settings.zon` —
Expand Down
Loading
Loading