Skip to content

Commit baef512

Browse files
Fix on-disk storage robustness and other pre-release review findings (#78)
* fix(storage): fail to read an absent object from the sync OPFS store OpfsSyncObjectStore.get() opened the file with create: true, like put(): reading an absent object created an empty file and returned no bytes, which were then taken for the object data. It now throws, like the asynchronous store. Assisted-by: Claude Opus 5.5 * fix(storage): keep the on-disk storage mode consistent on storage failures Enabling the on-disk storage mode kept the OPFS store before it was initialised and switched the mode before spilling the objects: after a failure (storage refused, write error), the runtime stayed in disk mode with an unusable store while the toggle showed memory storage, later operations failed although applied, and a further attempt reported the mode as enabled. The store is now kept once initialised only, and a failed switch pages the spilled objects back in and stays in RAM mode (the toggle follows the mode the runtime actually ended in). After a successful call, a storage failure (settling paged-in objects, clearing a replaced store, sweeping) made the call fail, so that running it again applied it twice, and skipped the next reconciliation steps. These steps are now best effort: each one is attempted, objects that cannot be written stay in memory, and the error is reported once through the new onStorageError listener (bridged from the kernel worker), which the app shows as a warning. Assisted-by: Claude Opus 5.5 * fix(aiassistant): number the macros saved from several code blocks Every "Save to Macros" button under the Python code blocks of a reply used the title derived from the request, so that saving several blocks created homonymous macros. With several blocks, each title now ends with the number of its block, as the button label does. Assisted-by: Claude Opus 5.5 * fix(remote): clone event payloads instead of transferring them Events are posted to every peer window, and post() transferred the buffers of each message: a transferred buffer is detached, so the second peer would have received empty buffers, and the caller of emit() lost its own. Events are now structured-cloned; responses, sent to a single peer, still transfer their buffers. Assisted-by: Claude Opus 5.5 * fix(h5browser): keep the paths of datasets reached through external links The tree registered each child at child_dset.name, which is the path of an object reached through an external link in its own file: that path may be missing from the browsed file, or designate another dataset of it, whose data was then previewed and imported. Children are now registered at their path in the browsed file. Browsed files are copied alone, so their external links are broken and skipped today: this mainly aligns the browser with SigimaX. Assisted-by: Claude Opus 5.5
2 parents 6a09bb6 + 849356e commit baef512

17 files changed

Lines changed: 640 additions & 42 deletions

‎CHANGELOG.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
1717
- **On-disk storage — failed writes:** when the browser refused to write an object to disk (storage quota exceeded, write error), the object lost its data: it had already been removed from memory. The object now stays in memory with its data, and the error is reported. An interrupted write no longer replaces the previous on-disk copy either.
1818
- **On-disk storage — incomplete transfers:** an object whose data the browser wrote to disk only partly was considered stored and removed from memory, so that it was later read back with missing values; a partial read also gave missing values without error. Incomplete writes now fail like other write errors, so that the object keeps its data in memory, and incomplete reads are reported as errors.
1919
- **On-disk storage — several tabs:** with DataLab-Web open in two tabs in on-disk storage mode, switching one tab back to memory storage or replacing its workspace deleted the data stored on disk by the other tab, whose objects could then no longer be displayed or processed. Each tab now keeps its data in its own storage area; the areas of closed tabs are reclaimed when the on-disk mode is next enabled.
20+
- **On-disk storage — enabling the mode:** when **Store data on disk** (in the menu of the memory indicator) could not be enabled (storage refused by the browser, as in the private windows of some browsers, or data that could not be written), DataLab-Web was left halfway: the toggle still showed memory storage, some objects were kept on disk, later operations were reported as failed although they had been applied, and a further attempt reported the mode as enabled while nothing was stored on disk. A failed attempt now leaves every object in memory, as before, and the mode can be enabled again once the cause is fixed.
21+
- **On-disk storage — operations reported as failed:** in on-disk storage mode, when the browser refused to write the result of an operation to disk (storage quota exceeded, write error), the operation was reported as failed although it had been applied, so that running it again applied it twice. The operation now succeeds: the objects that could not be written stay in memory with their data, a warning reports the storage error once, and these objects are stored on disk by a later operation when possible.
2022
- **HDF5 import — complex and integer data:** single-precision complex signals (`complex64`) lost their imaginary part, and unsigned 32-bit images were clipped to 2 147 483 647. Complex data is now imported as complex objects and integer data keeps its values exactly (images with large values become 64-bit floating-point images); a warning now reports integers beyond 2⁵³, which cannot be represented exactly. Previews show the real part of complex data.
2123
- **HDF5 browser — broken links:** files containing a link to a missing object or to a missing file (e.g. synchrotron master files without their detector files) could not be opened at all. Broken links are now skipped and the rest of the file can be browsed and imported.
2224
- **HDF5 browser — link cycles:** files containing a group linked into itself or into one of its subgroups were browsed down to a hundred phantom levels, or took too long to open. Such links back to a parent group are now skipped; other aliases are still shown at each of their paths.
@@ -26,7 +28,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
2628
- **Image viewer — pixel positions:** images were drawn half a pixel off their coordinates: the first pixel started at the image origin instead of being centered on it, as in Sigima and DataLab desktop. Regions of interest, analysis results, the profile crosshair and the statistics rectangle therefore appeared shifted by half a pixel relative to the computed pixels, and the value shown when hovering the image could be that of the neighbouring pixel. Each pixel is now centered on its coordinates in the image viewer, the multi-image views and the processing preview, so overlays, read-outs, cross-sections and statistics match the computations exactly. The default regions of interest and areas to erase are centered on the image, and the preview of _Create ROI grid…_ now shows the same whole-pixel cells as the created regions of interest. Cross-sections and statistics also work on images with non-uniform coordinates.
2729
- **Image viewer — blurred pixels when zooming in:** since version 0.6.1, zooming in on an image showed blurred pixels, each one fading into its neighbours, so that close values could not be told apart. Zoomed-in pixels are now drawn as sharp squares (nearest pixel, as in DataLab desktop) in the image viewer, the _Spatial_ multi-image view and the processing preview, in every browser and in the images exported from the plot. Zoomed-out images are displayed as before.
2830
- **AI Assistant — Save to Macros:** since version 0.10.0, clicking **Save to Macros** under a macro proposed by the AI Assistant showed "Saved ✓", but the macro did not appear in the Macros panel; each click added another hidden macro to the workspace. The saved macro now opens in the Macros panel, which is brought to the front, including from a conversation reopened from the history.
29-
- **AI Assistant — macros written as text:** asked to write a macro, the assistant could answer with the code in its reply, without any **Save to Macros** button, in particular for curves its creation tools cannot produce (super-Gaussian, Lorentzian, polynomial…), which it was told to create without a macro. The assistant now proposes such macros through its macro tool, without running them unless asked (the approval dialog says so, and approving such a macro never auto-approves a macro that runs), and a **Save to Macros** button is also offered under each Python code block of its replies.
31+
- **AI Assistant — macros written as text:** asked to write a macro, the assistant could answer with the code in its reply, without any **Save to Macros** button, in particular for curves its creation tools cannot produce (super-Gaussian, Lorentzian, polynomial…), which it was told to create without a macro. The assistant now proposes such macros through its macro tool, without running them unless asked (the approval dialog says so, and approving such a macro never auto-approves a macro that runs), and a **Save to Macros** button is also offered under each Python code block of its replies; the macros saved from a reply with several code blocks are numbered after the request they answer (e.g. _Plot two curves (2)_), so they can be told apart in the Macros panel.
3032
- **AI Assistant — macro output:** the assistant received an empty result when reading back the output of the last macro it ran.
3133

3234
### Changed

‎src/App.tsx‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1151,6 +1151,23 @@ export default function App() {
11511151
return unsubscribe;
11521152
}, [runtime, markDirty]);
11531153

1154+
// In on-disk storage mode, objects that could not be stored on disk after
1155+
// an operation (storage quota exceeded, write error) stay in memory: the
1156+
// operation succeeded, the storage error is reported once.
1157+
useEffect(() => {
1158+
if (!runtime) return;
1159+
return runtime.onStorageError((message) => {
1160+
void notify({
1161+
kind: "warning",
1162+
title: t("Data storage"),
1163+
message: t(
1164+
"Some data could not be stored on disk and is kept in memory: {message}",
1165+
{ message },
1166+
),
1167+
});
1168+
});
1169+
}, [runtime, notify]);
1170+
11541171
// Sync the "Paste metadata" availability from the Python clipboard
11551172
// once the runtime is ready. The Python ``_METADATA_CLIPBOARD`` is
11561173
// preserved across HMR reloads, so the React flag (which resets to
@@ -2282,6 +2299,9 @@ export default function App() {
22822299
: t("On-disk storage disabled. Object data is kept in memory."),
22832300
});
22842301
} catch (err) {
2302+
// The runtime normally stays in its previous mode, but reports the
2303+
// mode it actually ended in (see ``setStorageMode``).
2304+
setStoreOnDisk(runtime.getStorageMode() === "disk");
22852305
const message = err instanceof Error ? err.message : String(err);
22862306
await notify({
22872307
kind: "error",

‎src/components/AIAssistant/AIAssistantPanel.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -950,7 +950,8 @@ function CodeBlockSaveButtons({
950950
{blocks.map((code, i) => (
951951
<SaveMacroButton
952952
key={i}
953-
title={title}
953+
// Distinct titles for the macros of a reply with several blocks
954+
title={blocks.length > 1 ? `${title} (${i + 1})` : title}
954955
code={code}
955956
onSaveMacro={onSaveMacro}
956957
label={

‎src/locales/fr.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1199,6 +1199,7 @@
11991199
"Sobel filter (vertical)": "Filtre de Sobel (vertical)",
12001200
"Software": "Logiciel",
12011201
"Solid": "Continu",
1202+
"Some data could not be stored on disk and is kept in memory: {message}": "Des données n'ont pas pu être stockées sur le disque et sont conservées en mémoire : {message}",
12021203
"Some integer values could not be represented exactly and were rounded during import.": "Certaines valeurs entières ne pouvaient pas être représentées exactement et ont été arrondies lors de l'import.",
12031204
"Some selected objects have regions of interest. Do you want to delete them as well?": "Certains objets sélectionnés ont des régions d'intérêt. Souhaitez-vous les supprimer également ?",
12041205
"Source": "Source",

‎src/runtime/WorkerRuntimeProxy.ts‎

Lines changed: 35 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@
1313
*
1414
* * **Synchronous accessors** (``getStorageMode`` etc.) are served from a
1515
* locally-cached {@link KernelMirror} that the worker pushes on change.
16-
* * **Callback registrars** (``setDialogHandler``, ``onWorkspaceMutation``)
17-
* keep the JS callback on the main thread and bridge the worker's events
18-
* to it — a function can't cross ``postMessage``.
16+
* * **Callback registrars** (``setDialogHandler``, ``onWorkspaceMutation``,
17+
* ``onStorageError``) keep the JS callback on the main thread and bridge
18+
* the worker's events to it — a function can't cross ``postMessage``.
1919
* * Every other method is forwarded generically through a ``Proxy`` trap,
2020
* so the ~150-method surface needs no hand-written forwarders and any
2121
* new ``RuntimeApi`` method works automatically.
@@ -65,6 +65,7 @@ export class WorkerRuntimeProxy {
6565
private readonly pending = new Map<number, PendingCall>();
6666
private mirror: KernelMirror = INITIAL_MIRROR;
6767
private readonly mutationListeners = new Set<(name: string) => void>();
68+
private readonly storageErrorListeners = new Set<(message: string) => void>();
6869
private dialogHandler:
6970
((kind: string, payload: unknown) => Promise<unknown>) | null = null;
7071

@@ -173,14 +174,18 @@ export class WorkerRuntimeProxy {
173174
}
174175
if (prop === "onWorkspaceMutation") {
175176
return (listener: (name: string) => void) =>
176-
this.addMutationListener(listener);
177+
this.addListener(this.mutationListeners, listener);
178+
}
179+
if (prop === "onStorageError") {
180+
return (listener: (message: string) => void) =>
181+
this.addListener(this.storageErrorListeners, listener);
177182
}
178183
// Generic async forward for every other method.
179184
return (...args: unknown[]) => this.call(prop, args);
180185
},
181186
};
182187
// The Proxy structurally satisfies RuntimeApi: every access yields a
183-
// function with the right shape (sync accessors, the two callback
188+
// function with the right shape (sync accessors, the callback
184189
// registrars, or a generic async forward).
185190
return new Proxy({}, handler) as unknown as RuntimeApi;
186191
}
@@ -219,13 +224,31 @@ export class WorkerRuntimeProxy {
219224
});
220225
}
221226

222-
private addMutationListener(listener: (name: string) => void): () => void {
223-
this.mutationListeners.add(listener);
227+
private addListener<T>(
228+
listeners: Set<(value: T) => void>,
229+
listener: (value: T) => void,
230+
): () => void {
231+
listeners.add(listener);
224232
return () => {
225-
this.mutationListeners.delete(listener);
233+
listeners.delete(listener);
226234
};
227235
}
228236

237+
/** Call every listener of *listeners* with *value*, logging their errors. */
238+
private notify<T>(
239+
listeners: Set<(value: T) => void>,
240+
value: T,
241+
kind: string,
242+
): void {
243+
for (const cb of listeners) {
244+
try {
245+
cb(value);
246+
} catch (err) {
247+
console.warn(`[workerRuntime] ${kind} listener threw`, err);
248+
}
249+
}
250+
}
251+
229252
private handleEvent(event: KernelEvent): void {
230253
switch (event.type) {
231254
case "progress":
@@ -255,13 +278,10 @@ export class WorkerRuntimeProxy {
255278
break;
256279
}
257280
case "mutation":
258-
for (const cb of this.mutationListeners) {
259-
try {
260-
cb(event.name);
261-
} catch (err) {
262-
console.warn("[workerRuntime] mutation listener threw", err);
263-
}
264-
}
281+
this.notify(this.mutationListeners, event.name, "mutation");
282+
break;
283+
case "storage-error":
284+
this.notify(this.storageErrorListeners, event.message, "storage-error");
265285
break;
266286
case "dialog-request":
267287
void this.handleDialogRequest(event.id, event.kind, event.payload);

‎src/runtime/dlw_h5browser.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -426,10 +426,10 @@ def _build_node(
426426
)
427427
continue
428428
try:
429+
# Path in the opened file: ``child_dset.name`` is the path of an
430+
# object reached through an external link in its own file
429431
info.children.append(
430-
_build_node(
431-
file_id, child_dset.name, child_dset, _depth + 1, ancestors
432-
)
432+
_build_node(file_id, child_path, child_dset, _depth + 1, ancestors)
433433
)
434434
except (UnicodeDecodeError, TypeError, ValueError, KeyError) as exc:
435435
print(f"[h5browser] skip {child_path!r}: {type(exc).__name__}: {exc}")

‎src/runtime/kernelWorker.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,8 @@ async function boot(lang: string, labels: RuntimeLabels): Promise<void> {
8484
);
8585
// Forward workspace mutations so the main thread can flip its dirty flag.
8686
rt.onWorkspaceMutation((name) => post({ type: "mutation", name }));
87+
// Forward on-disk storage errors, which the main thread reports.
88+
rt.onStorageError((message) => post({ type: "storage-error", message }));
8789
// Route Python dialog requests up to the main thread, which owns the UI.
8890
rt.setDialogHandler(
8991
(kind, payload) =>

‎src/runtime/remoteBridge.ts‎

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
*
2222
* Responses with binary payloads (e.g. ``Float64Array``) attach those
2323
* buffers as ``Transferable`` to ``postMessage`` for zero-copy transfer.
24+
* Events, sent to every peer, are cloned instead.
2425
*/
2526

2627
import { buildProxyBridge, type BridgeMethod } from "./proxyBridge";
@@ -184,14 +185,18 @@ export function activateRemoteBridge(
184185
target: MessageEventSource,
185186
origin: string,
186187
msg: OutboundMessage,
188+
transferBuffers?: boolean,
187189
) => void)
188190
| null = null;
189191

192+
// Events are cloned, never transferred: they go to every peer (a buffer
193+
// transferred to the first one would arrive empty at the others), and
194+
// their payload belongs to the caller, whose buffers must stay usable.
190195
const flushEvents = () => {
191196
if (!post || queuedEvents.length === 0 || peers.size === 0) return;
192197
const drained = queuedEvents.splice(0);
193198
for (const [peer, origin] of peers) {
194-
for (const evt of drained) post(peer, origin, evt);
199+
for (const evt of drained) post(peer, origin, evt, false);
195200
}
196201
};
197202

@@ -237,8 +242,13 @@ export function activateRemoteBridge(
237242
get_protocol_version: async () => RPC_PROTOCOL_VERSION,
238243
};
239244

240-
post = (target: MessageEventSource, origin: string, msg: OutboundMessage) => {
241-
const transfer = collectTransferables(msg);
245+
post = (
246+
target: MessageEventSource,
247+
origin: string,
248+
msg: OutboundMessage,
249+
transferBuffers = true,
250+
) => {
251+
const transfer = transferBuffers ? collectTransferables(msg) : [];
242252
const targetOrigin = peerTargetOrigin(origin);
243253
try {
244254
// ``MessageEventSource`` has a narrower type than ``Window`` but
@@ -312,7 +322,7 @@ export function activateRemoteBridge(
312322
},
313323
emit(name, payload) {
314324
const evt: RpcEvent = { type: "event", name, payload };
315-
for (const [peer, origin] of peers) post(peer, origin, evt);
325+
for (const [peer, origin] of peers) post(peer, origin, evt, false);
316326
},
317327
};
318328
}

0 commit comments

Comments
 (0)