Skip to content

Commit

Permalink
fix(xsnap): Avoid snapshot data memory leak (#9405)
Browse files Browse the repository at this point in the history
Fixes #9316

Add testing code to observe memory leaks related to xsnap snapshots, and
fix the leak that retains them.

None.

None.

None.

Covered!

None (this is kernel code).
  • Loading branch information
mergify[bot] authored and mhofman committed May 30, 2024
1 parent 734e863 commit c96e1d2
Show file tree
Hide file tree
Showing 4 changed files with 379 additions and 79 deletions.
1 change: 1 addition & 0 deletions packages/xsnap/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
},
"devDependencies": {
"@endo/base64": "0.2.31",
"@endo/nat": "4.1.27",
"ava": "^5.2.0",
"c8": "^7.13.0"
},
Expand Down
184 changes: 105 additions & 79 deletions packages/xsnap/src/xsnap.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,93 @@ function echoCommand(arg) {
const safeHintFromDescription = description =>
description.replaceAll(/[^a-zA-Z0-9_.-]/g, '-');

/**
* @typedef {object} SnapshotLoader
* @property {string} snapPath
* where XS can load the snapshot from, either a filesystem path or a string
* like `@${fileDescriptorNumber}:${readableDescription}`
* @property {(destStream?: Writable) => Promise<void>} afterSpawn
* callback for providing a destination stream to which the data should be
* piped (only relevant for a stream-based loader)
* @property {() => Promise<void>} cleanup
* callback to free resources when the loader is no longer needed
*/

/**
* @callback MakeSnapshotLoader
* @param {AsyncIterable<Uint8Array>} sourceBytes
* @param {string} description
* @param {{fs: Pick<typeof import('fs/promises'), 'open' | 'unlink'>, ptmpName: (opts: import('tmp').TmpNameOptions) => Promise<string>}} ioPowers
* @returns {Promise<SnapshotLoader>}
*/

/** @type {MakeSnapshotLoader} */
const makeSnapshotLoaderWithFS = async (
sourceBytes,
description,
{ fs, ptmpName },
) => {
const snapPath = await ptmpName({
template: `load-snapshot-${safeHintFromDescription(
description,
)}-XXXXXX.xss`,
});

const afterSpawn = async () => {};
const cleanup = async () => fs.unlink(snapPath);

try {
// eslint-disable-next-line @jessie.js/no-nested-await
const tmpSnap = await fs.open(snapPath, 'w');
// eslint-disable-next-line @jessie.js/no-nested-await
await tmpSnap.writeFile(
// @ts-expect-error incorrect typings, does support AsyncIterable
sourceBytes,
);
// eslint-disable-next-line @jessie.js/no-nested-await
await tmpSnap.close();
} catch (e) {
// eslint-disable-next-line @jessie.js/no-nested-await
await cleanup();
throw e;
}

return harden({
snapPath,
afterSpawn,
cleanup,
});
};

/** @type {MakeSnapshotLoader} */
const makeSnapshotLoaderWithPipe = async (
sourceBytes,
description,
_ioPowers,
) => {
let done = Promise.resolve();

const cleanup = async () => done;

/** @param {Writable} loadSnapshotsStream */
const afterSpawn = async loadSnapshotsStream => {
assert(description);
const destStream = loadSnapshotsStream;

const sourceStream = Readable.from(sourceBytes);
sourceStream.pipe(destStream, { end: false });

done = finished(sourceStream);
done.catch(noop).then(() => sourceStream.unpipe(destStream));
};

return harden({
snapPath: `@${SNAPSHOT_LOAD_FD}:${safeHintFromDescription(description)}`,
afterSpawn,
cleanup,
});
};

/**
* @param {XSnapOptions} options
*
Expand Down Expand Up @@ -86,7 +173,7 @@ export async function xsnap(options) {
netstringMaxChunkSize = undefined,
parserBufferSize = undefined,
snapshotStream,
snapshotDescription = snapshotStream && 'unknown',
snapshotDescription = 'unknown',
snapshotUseFs = false,
stdout = 'ignore',
stderr = 'ignore',
Expand All @@ -103,70 +190,6 @@ export async function xsnap(options) {
if (platform === undefined) {
throw Error(`xsnap does not support platform ${os}`);
}

/** @type {(opts: import('tmp').TmpNameOptions) => Promise<string>} */
const ptmpName = fs.tmpName && promisify(fs.tmpName);

const makeLoadSnapshotHandlerWithFS = async () => {
assert(snapshotStream);
const snapPath = await ptmpName({
template: `load-snapshot-${safeHintFromDescription(
snapshotDescription,
)}-XXXXXX.xss`,
});

const afterSpawn = async () => {};
const cleanup = async () => fs.unlink(snapPath);

try {
// eslint-disable-next-line @jessie.js/no-nested-await
const tmpSnap = await fs.open(snapPath, 'w');
// eslint-disable-next-line @jessie.js/no-nested-await
await tmpSnap.writeFile(
// @ts-expect-error incorrect typings, does support AsyncIterable
snapshotStream,
);
// eslint-disable-next-line @jessie.js/no-nested-await
await tmpSnap.close();
} catch (e) {
// eslint-disable-next-line @jessie.js/no-nested-await
await cleanup();
throw e;
}

return harden({
snapPath,
afterSpawn,
cleanup,
});
};

const makeLoadSnapshotHandlerWithPipe = async () => {
let done = Promise.resolve();

const cleanup = async () => done;

/** @param {Writable} loadSnapshotsStream */
const afterSpawn = async loadSnapshotsStream => {
assert(snapshotStream);
const destStream = loadSnapshotsStream;

const sourceStream = Readable.from(snapshotStream);
sourceStream.pipe(destStream, { end: false });

done = finished(sourceStream);
done.catch(noop).then(() => sourceStream.unpipe(destStream));
};

return harden({
snapPath: `@${SNAPSHOT_LOAD_FD}:${safeHintFromDescription(
snapshotDescription,
)}`,
afterSpawn,
cleanup,
});
};

let bin = new URL(
`../xsnap-native/xsnap/build/bin/${platform}/${
debug ? 'debug' : 'release'
Expand All @@ -179,15 +202,18 @@ export async function xsnap(options) {

assert(!/^-/.test(name), `name '${name}' cannot start with hyphen`);

let loadSnapshotHandler = await (snapshotStream &&
(snapshotUseFs
? makeLoadSnapshotHandlerWithFS
: makeLoadSnapshotHandlerWithPipe)());
/** @type {(opts: import('tmp').TmpNameOptions) => Promise<string>} */
const ptmpName = fs.tmpName && promisify(fs.tmpName);
const makeSnapshotLoader = snapshotUseFs
? makeSnapshotLoaderWithFS
: makeSnapshotLoaderWithPipe;
let snapshotLoader = await (snapshotStream &&
makeSnapshotLoader(snapshotStream, snapshotDescription, { fs, ptmpName }));

let args = [name];

if (loadSnapshotHandler) {
args.push('-r', loadSnapshotHandler.snapPath);
if (snapshotLoader) {
args.push('-r', snapshotLoader.snapPath);
}

if (meteringLimit) {
Expand Down Expand Up @@ -258,13 +284,13 @@ export async function xsnap(options) {
const snapshotSaveStream = xsnapProcessStdio[SNAPSHOT_SAVE_FD];
const snapshotLoadStream = xsnapProcessStdio[SNAPSHOT_LOAD_FD];

await loadSnapshotHandler?.afterSpawn(snapshotLoadStream);
await snapshotLoader?.afterSpawn(snapshotLoadStream);

if (loadSnapshotHandler) {
if (snapshotLoader) {
vatExit.promise.catch(noop).then(() => {
if (loadSnapshotHandler) {
const { cleanup } = loadSnapshotHandler;
loadSnapshotHandler = undefined;
if (snapshotLoader) {
const { cleanup } = snapshotLoader;
snapshotLoader = undefined;
return cleanup();
}
});
Expand All @@ -286,9 +312,9 @@ export async function xsnap(options) {
async function runToIdle() {
for await (const _ of forever) {
const iteration = await messagesFromXsnap.next(undefined);
if (loadSnapshotHandler) {
const { cleanup } = loadSnapshotHandler;
loadSnapshotHandler = undefined;
if (snapshotLoader) {
const { cleanup } = snapshotLoader;
snapshotLoader = undefined;
// eslint-disable-next-line @jessie.js/no-nested-await
await cleanup();
}
Expand Down
Loading

0 comments on commit c96e1d2

Please sign in to comment.