mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-11-04 12:57:45 +00:00
* WIP: Haskell impl of resizeImageToStrSize * WIP: add FFI export wrapper * WIP: switch ios resizeImageToStrSizeSync to backend * WIP: fix resizeImageToStrSizeSync * WIP: add JNI wrappers * Add non-base64 resize and cWriteImage * WIP: add chat_write_image * fix resizeToStr * fixes * fix syntax, raise jpeg quality back * add chatWriteImage * Fix cfArgs in tests * Use jpeg-turbo encoder * add nix pins * bump jpeg-turbo * add CI deps * add encoding funs to android lib * bump jpeg-turbo for 20.04 fix * jpeg-turbo fix for 8.10 * android fixes * Bump jpeg-turbo * set static flag for jpeg-turbo * Fix nix for aarch64/android * Fix android C api * Fix resize in android * Bump hs-libjpeg-turbo * target correct override * override android lib from android pkgs * add macos build tweaks * bump hs-jpeg-turbo for 8.10 fixes * more build fixes * make ios compile * Always use pkgconfig for libjpeg-turbo * WIP: debug libjpeg in ubuntu pkg-config CI * Add pkg-config to dockerfiles * Add libturbojpeg to dockerfiles * Use -dev version of libturbojpeg in dockerfiles * Install pkg-config in windows CI * Disable flag for custom libturbojpeg linking options Workarounds relocation error on x86_64-linux with gcc: /usr/bin/ld: /usr/lib/gcc/x86_64-linux-gnu/13/../../../x86_64-linux-gnu/libturbojpeg.a(turbojpeg.c.o): relocation R_X86_64_TPOFF32 against `errStr' can not be used when making a shared object; recompile with -fPIC /usr/bin/ld: failed to set dynamic section sizes: bad value * WIP: debug libjpeg in windows pkg-config CI * WIP: debug libjpeg package in windows CI * core: image resize module and buffer FFI * ci: link libjpeg-turbo statically on all platforms * ui: resize images in the core * core: rename image resize functions to chat_resize_image and chat_resize_image_uri * ci: restore whitespace in build.yml * ci: build libjpeg-turbo 3.1.4.1 from a submodule * core: hs-jpeg-turbo from github.com/simplex-chat * nodejs: resize images * python: resize images * core: hs-jpeg-turbo from gitlab.com, the GitHub import lacks LFS objects * core: generate /img previews * ci: link libjpeg-turbo on Windows without pkg-config * ci: build libjpeg-turbo on Windows with the GHC toolchain * docs: plan for image resizing in the core * docs: update image resizing plan * ios: prevent crash when wallpaper image cannot be resized * android, desktop: raise an error when an image cannot be resized * docs: update spec references for image resizing * docker: build the CLI image from a clone without submodules * docs: apply review fixes to image resizing plan * docker: build the CLI from a git worktree with the submodule present * docs: apply second review to image resizing plan * docs: point image resizing plan at the rewritten commit * core: choose image format by transparency in chat_resize_image * nodejs: resize images to the app sizes * python: resize images to the app sizes * nodejs, python: describe the core modules as used for image resizing * docs: add license notices of the image libraries * ci: describe the avatar method without attributing it to the apps * ci, ios, nix: remove the libjpeg-turbo build outside cabal * python: name resize_image in the core module text * nodejs, python: describe resizing to the app image sizes * core: refuse images larger than the supported size * core: resize to JPEG images with a side above 65500 pixels * docs: record image resizing decisions for the bundled libjpeg-turbo * core, docs: build libjpeg-turbo and stb from the fork submodules * docs: record the fork pins in the image resizing plan * core: resize images to at most 4320 pixels per side, refuse above 16384 * docs: record the image side limits in the image resizing plan * core: pin the rebased hs-jpeg-turbo fork * core: pin the hs-jpeg-turbo fork without package.yaml * docs: plan scaled JPEG decoding and EXIF orientation of images * docs: copy the JuicyPixels-stbir licence of the pinned fork * core: pin the hs-jpeg-turbo fork with scaled JPEG decoding * core: decode JPEG at the display size, up to 65500 pixels per side * core: rotate JPEG images by their EXIF orientation * docs: refer the image plan to the JPEG decoding code * docs: record the approved image processing decisions * core: limit JPEG decoding memory to 1024 MB outside iOS and Android * docs: name JPEG decoding in the libjpeg-turbo notice * docs: plan image resizing from files and the decisions of 26 Oct * core: scale images in one Catmull-Rom pass with sharpening * docs: plan image sizes at a fixed quality * core: resize images at a fixed quality to fill the size limit * core: resize images from a file path or a file descriptor * ios, android, desktop: resize picked images from their files * core: encode resized images with mozjpeg * tests: check descriptor closing by the file it refers to * core: raise JPEG quality only for a full-size image below 97% of the limit * ci: name the mozjpeg submodule in the Windows cleanup comments * docs: refer the image plan to the current code * docs: refer the image plan to the code merged with master * core: export every image resizing function from each library * core: close the file descriptor when its handle cannot be created * docs: count the C sources of mozjpeg with the arithmetic decoder * core: use the reviewed hs-jpeg-turbo and JuicyPixels-stbir forks * ci: build the armv7a Android core library on request * core: round image heights half up in the size search * ios: describe the file image resizing functions in SimpleX.h * ci: pick, preview and send images in the Claude app tests * core: decode PNG images with libspng and refuse other formats * core: encode PNG with libspng, keeping the smaller filter choice * ci: pick, preview and send images in the scripted app smoke tests * flake: build the PNG decoder package in nix builds * ios: remove the copy of a picked image after sending it * core: refuse image files larger than the decode memory limit * core: scale a JPEG image to its largest size once per resize * core: bisect JPEG quality without a step function * core: reduce the memory used to sharpen and to decode PNG images * android, desktop: pass the image format to the core as an integer * android, desktop: fuse the preview conditions of picked media * android, desktop, ci: check that the core resized the smoke test images * python: remove the docstrings of the explicit image resizing functions * docs: add the second review fixes to the image plan * ci: tap the iOS attachment button by its accessibility label * core: keep the colour profiles of resized images * docs: add the colour profiles to the image plan * ci: wait up to 30 s for the new group screens in the iOS smoke test * ci: pick the Android smoke test images from their own photo folders * ci: tap the iOS photo picker by position, as its tree is not shown * ci: pick the test card and the logo by their iOS picker positions * core: update JuicyPixels-stbir with the stb_image_resize licence terms * core: write a fixed Display P3 profile for Display P3 images only * core: do not keep the input image in memory for its colour profile * core: read the JPEG segments once for the orientation and the profile * core: read an image file into one buffer of its size * docs: add the third review fixes to the image plan * core: resize images with Display P3 or no profile, not profile bytes * ci: check the pixel sizes of the images sent in the iOS smoke test * core: update hs-jpeg-turbo with PNG chunk limits and encoder fixes * docs: list Compact ICC Profiles, libspng and miniz in the dependencies * android, desktop: reword the picked media preview comment * docs: refer the image plan to the code merged with master * ci: render the iOS snapshots before the images case * ci: dismiss the snapshot tests' camera alert before the iOS images case * core: update hs-jpeg-turbo with tests and the JuicyPixels-spng synopsis * core: pin hs-jpeg-turbo and JuicyPixels-stbir in simplex-chat repositories * update ios core library * core, ui: accept only JPEG and PNG image data * core: update JuicyPixels-stbir * docs: plan for the iOS copy of picked images --------- Co-authored-by: IC Rainbow <aenor.realm@gmail.com> Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com> Co-authored-by: Alexandre Esteves <alexfmpe@proton.me>
390 lines
19 KiB
TypeScript
390 lines
19 KiB
TypeScript
import {execFile, spawnSync} from "child_process";
|
|
import * as crypto from "crypto";
|
|
import * as fs from "fs";
|
|
import * as path from "path";
|
|
import * as zlib from "zlib";
|
|
import {core} from "../src/index";
|
|
import * as libs from "../src/libs";
|
|
import * as simplex from "../src/simplex";
|
|
|
|
describe("Core tests", () => {
|
|
let libPath: string;
|
|
let addonPath: string;
|
|
// the first run downloads libsimplex
|
|
beforeAll(async () => {
|
|
const libsDir = await libs.resolveLibsDir("sqlite");
|
|
libPath = libs.libPath(libsDir);
|
|
addonPath = await libs.resolveAddonPath();
|
|
await core.loadLibrary("sqlite");
|
|
}, 300000);
|
|
const tmpDir = "./tests/tmp";
|
|
const dbPath = path.join(tmpDir, "simplex_v1");
|
|
const templateDir = "./tests/template";
|
|
const templateDbPath = path.join(templateDir, "simplex_v1");
|
|
|
|
beforeAll(async () => {
|
|
fs.mkdirSync(templateDir, {recursive: true});
|
|
await stopAndClose(await core.chatMigrateInit(templateDbPath, "key", core.MigrationConfirmation.YesUp));
|
|
}, 30000);
|
|
afterAll(() => fs.rmSync(templateDir, {recursive: true, force: true}));
|
|
beforeEach(() => {
|
|
fs.mkdirSync(tmpDir, {recursive: true});
|
|
copyTemplateDb(dbPath);
|
|
});
|
|
afterEach(() => fs.rmSync(tmpDir, {recursive: true, force: true}));
|
|
|
|
async function stopAndClose(ctrl: bigint): Promise<void> {
|
|
await expect(core.chatSendCmd(ctrl, "/_stop")).resolves.toMatchObject({type: "chatStopped"});
|
|
await core.chatCloseStore(ctrl);
|
|
}
|
|
|
|
function copyTemplateDb(dbFilePrefix: string): void {
|
|
for (const suffix of ["_chat.db", "_agent.db"]) fs.copyFileSync(templateDbPath + suffix, dbFilePrefix + suffix);
|
|
}
|
|
|
|
it("should initialize chat controller", async () => {
|
|
const ctrl = await core.chatMigrateInit(dbPath, "key", core.MigrationConfirmation.YesUp);
|
|
expect(typeof ctrl).toBe("bigint");
|
|
await expect(stopAndClose(ctrl)).resolves.toBe(undefined);
|
|
|
|
await expect(core.chatMigrateInit(dbPath, "wrong_key", core.MigrationConfirmation.YesUp)).rejects.toMatchObject({
|
|
message: "Database or migration error (see dbMigrationError property)",
|
|
dbMigrationError: expect.objectContaining({type: "errorNotADatabase"})
|
|
});
|
|
});
|
|
|
|
it("should initialize chat controller with queue size", async () => {
|
|
const ctrl = await core.chatMigrateInit(dbPath, "key", core.MigrationConfirmation.YesUp, 65536);
|
|
expect(typeof ctrl).toBe("bigint");
|
|
await expect(stopAndClose(ctrl)).resolves.toBe(undefined);
|
|
|
|
await expect(core.chatMigrateInit(dbPath, "key", core.MigrationConfirmation.YesUp, 0)).rejects.toMatchObject({
|
|
dbMigrationError: {type: "invalidQueueSize"}
|
|
});
|
|
await expect(core.chatMigrateInit(dbPath, "key", core.MigrationConfirmation.YesUp, 2 ** 31)).rejects.toThrow("Expected 32-bit integer queue size");
|
|
await expect(core.chatMigrateInit(dbPath, "key", core.MigrationConfirmation.YesUp, 1.5)).rejects.toThrow("Expected 32-bit integer queue size");
|
|
});
|
|
|
|
it("should send command and receive event", async () => {
|
|
const ctrl = await core.chatMigrateInit(dbPath, "key", core.MigrationConfirmation.YesUp);
|
|
|
|
await expect(core.chatSendCmd(ctrl, "/v")).resolves.toMatchObject({
|
|
type: "versionInfo"
|
|
});
|
|
await expect(core.chatSendCmd(ctrl, '/debug event {"type": "chatSuspended"}')).resolves.toMatchObject({
|
|
type: "cmdOk"
|
|
});
|
|
|
|
const wait = 500_000;
|
|
await expect(core.chatRecvMsgWait(ctrl, wait)).resolves.toMatchObject({
|
|
type: "chatSuspended"
|
|
});
|
|
await expect(core.chatRecvMsgWait(ctrl, wait)).resolves.toBe(undefined);
|
|
|
|
await expect(core.chatSendCmd(ctrl, "/unknown")).rejects.toMatchObject({
|
|
message: "Chat command error (see chatError property)",
|
|
chatError: expect.objectContaining({type: "error"})
|
|
});
|
|
|
|
await stopAndClose(ctrl);
|
|
});
|
|
|
|
it("should write/read encrypted file from/to buffer", async () => {
|
|
const ctrl = await core.chatMigrateInit(dbPath, "key", core.MigrationConfirmation.YesUp);
|
|
|
|
const filePath = path.join(tmpDir, "write_file.txt");
|
|
const buffer = new Uint8Array([0, 1, 2]).buffer;
|
|
const cryptoArgs = await core.chatWriteFile(ctrl, filePath, buffer);
|
|
expect(typeof cryptoArgs.fileKey).toBe("string");
|
|
expect(typeof cryptoArgs.fileNonce).toBe("string");
|
|
|
|
const buffer2 = await core.chatReadFile(filePath, cryptoArgs);
|
|
expect(Buffer.from(buffer2).equals(Buffer.from(buffer))).toBe(true);
|
|
|
|
await expect(core.chatWriteFile(ctrl, path.join(tmpDir, "unknown", "unknown.txt"), buffer)).rejects.toThrow();
|
|
await expect(core.chatReadFile(path.join(tmpDir, "unknown.txt"), cryptoArgs)).rejects.toThrow();
|
|
|
|
await stopAndClose(ctrl);
|
|
});
|
|
|
|
it("should write the view of a Uint8Array and read an empty file", async () => {
|
|
const ctrl = await core.chatMigrateInit(dbPath, "key", core.MigrationConfirmation.YesUp);
|
|
|
|
const viewPath = path.join(tmpDir, "view.txt");
|
|
const viewArgs = await core.chatWriteFile(ctrl, viewPath, Buffer.from("xxabcxx").subarray(2, 5));
|
|
const view = await core.chatReadFile(viewPath, viewArgs);
|
|
expect(Buffer.isBuffer(view)).toBe(true);
|
|
expect(view.toString()).toBe("abc");
|
|
|
|
const emptyPath = path.join(tmpDir, "empty.txt");
|
|
const emptyArgs = await core.chatWriteFile(ctrl, emptyPath, new Uint8Array(0));
|
|
const empty = await core.chatReadFile(emptyPath, emptyArgs);
|
|
expect(Buffer.isBuffer(empty)).toBe(true);
|
|
expect(empty.length).toBe(0);
|
|
|
|
await stopAndClose(ctrl);
|
|
});
|
|
|
|
it("should encrypt/decrypt file", async () => {
|
|
const ctrl = await core.chatMigrateInit(dbPath, "key", core.MigrationConfirmation.YesUp);
|
|
|
|
const unencryptedPath = path.join(tmpDir, "file_unencrypted.txt");
|
|
fs.writeFileSync(unencryptedPath, "unencrypted\n");
|
|
const encryptedPath = path.join(tmpDir, "file_encrypted.txt");
|
|
const cryptoArgs = await core.chatEncryptFile(ctrl, unencryptedPath, encryptedPath);
|
|
expect(typeof cryptoArgs.fileKey).toBe("string");
|
|
expect(typeof cryptoArgs.fileNonce).toBe("string");
|
|
|
|
const decryptedPath: string = path.join(tmpDir, "file_decrypted.txt");
|
|
await expect(core.chatDecryptFile(encryptedPath, cryptoArgs, decryptedPath)).resolves.toBe(undefined);
|
|
|
|
expect(fs.readFileSync(decryptedPath, "utf8")).toBe("unencrypted\n");
|
|
|
|
await expect(core.chatEncryptFile(ctrl, path.join(tmpDir, "unknown.txt"), encryptedPath)).rejects.toThrow();
|
|
await expect(core.chatDecryptFile(path.join(tmpDir, "unknown.txt"), cryptoArgs, decryptedPath)).rejects.toThrow();
|
|
|
|
await stopAndClose(ctrl);
|
|
});
|
|
|
|
describe("image resizing", () => {
|
|
const notImage = Buffer.from("not an image");
|
|
const resizeError = "image cannot be decoded or does not fit maxSize";
|
|
const oldLibraryError = (name: string) => `loaded libsimplex does not export ${name}; image resizing needs a newer libsimplex`;
|
|
let imageResizing = false;
|
|
|
|
beforeAll(async () => {
|
|
const error = await core.chatResizeImageUri(notImage, 14000).then(() => "", (e: Error) => e.message);
|
|
imageResizing = error !== oldLibraryError("chat_resize_image_uri");
|
|
});
|
|
|
|
function noisePng(width: number, height: number, alpha = false): Buffer {
|
|
const rowLength = width * (alpha ? 4 : 3) + 1;
|
|
const rows = crypto.randomBytes(rowLength * height);
|
|
for (let y = 0; y < height; y++) rows[y * rowLength] = 0;
|
|
const header = Buffer.alloc(13);
|
|
header.writeUInt32BE(width, 0);
|
|
header.writeUInt32BE(height, 4);
|
|
header.set([8, alpha ? 6 : 2, 0, 0, 0], 8);
|
|
const chunk = (type: string, data: Buffer) => {
|
|
const body = Buffer.concat([Buffer.from(type), data]);
|
|
const length = Buffer.alloc(4);
|
|
length.writeUInt32BE(data.length);
|
|
const crc = Buffer.alloc(4);
|
|
crc.writeUInt32BE(zlib.crc32(body));
|
|
return Buffer.concat([length, body, crc]);
|
|
};
|
|
const signature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
|
return Buffer.concat([signature, chunk("IHDR", header), chunk("IDAT", zlib.deflateSync(rows)), chunk("IEND", Buffer.alloc(0))]);
|
|
}
|
|
|
|
it("should resize image to data URI", async () => {
|
|
const resize = core.chatResizeImageUri(noisePng(256, 256), 14000);
|
|
if (!imageResizing) return expect(resize).rejects.toThrow(oldLibraryError("chat_resize_image_uri"));
|
|
const uri = await resize;
|
|
expect(uri.startsWith("data:image/jpg;base64,")).toBe(true);
|
|
expect(uri.length).toBeLessThanOrEqual(14000);
|
|
});
|
|
|
|
it("should resize the view of a Uint8Array to JPEG and PNG", async () => {
|
|
const image = noisePng(256, 256);
|
|
const view = Buffer.concat([Buffer.alloc(3), image]).subarray(3);
|
|
const resize = core.chatResizeImage(view, 20000, "jpeg");
|
|
if (!imageResizing) return expect(resize).rejects.toThrow(oldLibraryError("chat_resize_image"));
|
|
const jpeg = await resize;
|
|
expect(jpeg.subarray(0, 3)).toEqual(Buffer.from([0xff, 0xd8, 0xff]));
|
|
expect(jpeg.length).toBeLessThanOrEqual(20000);
|
|
const png = await core.chatResizeImage(new Uint8Array(image).buffer, 20000, "png");
|
|
expect(png.subarray(0, 8)).toEqual(image.subarray(0, 8));
|
|
expect(png.length).toBeLessThanOrEqual(20000);
|
|
});
|
|
|
|
it("should resize image to each ImageSize", async () => {
|
|
const image = noisePng(512, 512);
|
|
const resize = core.resizeImage(image, core.ImageSize.Image);
|
|
if (!imageResizing) return expect(resize).rejects.toThrow(oldLibraryError("chat_resize_image"));
|
|
const jpeg = await resize;
|
|
expect(jpeg.subarray(0, 3)).toEqual(Buffer.from([0xff, 0xd8, 0xff]));
|
|
expect(jpeg.length).toBeLessThanOrEqual(261120);
|
|
const profile = await core.resizeImage(image, core.ImageSize.ProfilePreview);
|
|
expect(profile.startsWith("data:image/jpg;base64,")).toBe(true);
|
|
expect(profile.length).toBeLessThanOrEqual(12500);
|
|
const preview = await core.resizeImage(image, core.ImageSize.MessagePreview);
|
|
expect(preview.startsWith("data:image/jpg;base64,")).toBe(true);
|
|
expect(preview.length).toBeLessThanOrEqual(14000);
|
|
});
|
|
|
|
it("should resize image with transparency to PNG for each ImageSize", async () => {
|
|
const image = noisePng(256, 256, true);
|
|
const resize = core.resizeImage(image, core.ImageSize.Image);
|
|
if (!imageResizing) return expect(resize).rejects.toThrow(oldLibraryError("chat_resize_image"));
|
|
const png = await resize;
|
|
expect(png.subarray(0, 8)).toEqual(image.subarray(0, 8));
|
|
expect(png.length).toBeLessThanOrEqual(261120);
|
|
const profile = await core.resizeImage(image, core.ImageSize.ProfilePreview);
|
|
expect(profile.startsWith("data:image/png;base64,")).toBe(true);
|
|
expect(profile.length).toBeLessThanOrEqual(12500);
|
|
const preview = await core.resizeImage(image, core.ImageSize.MessagePreview);
|
|
expect(preview.startsWith("data:image/png;base64,")).toBe(true);
|
|
expect(preview.length).toBeLessThanOrEqual(14000);
|
|
});
|
|
|
|
it("should reject data that is not an image", async () => {
|
|
await expect(core.chatResizeImage(notImage, 20000, "jpeg")).rejects.toThrow(imageResizing ? resizeError : oldLibraryError("chat_resize_image"));
|
|
});
|
|
|
|
it("should reject maxSize outside 32-bit integers", async () => {
|
|
const error = imageResizing ? "Expected 32-bit integer maxSize" : oldLibraryError("chat_resize_image_uri");
|
|
await expect(core.chatResizeImageUri(noisePng(8, 8), 2 ** 31)).rejects.toThrow(error);
|
|
});
|
|
});
|
|
|
|
it("should not block the libuv pool while receiving", async () => {
|
|
const ctrl = await core.chatMigrateInit(dbPath, "key", core.MigrationConfirmation.YesUp);
|
|
const receives = [1, 2, 3, 4].map(() => core.chatRecvMsgWait(ctrl, 500_000));
|
|
const start = Date.now();
|
|
await fs.promises.stat(tmpDir);
|
|
expect(Date.now() - start).toBeLessThan(200);
|
|
// one receiver thread per controller runs the 4 receives in turn
|
|
await Promise.all(receives);
|
|
await stopAndClose(ctrl);
|
|
}, 30000);
|
|
|
|
const itOnLinux = process.platform === "linux" ? it : it.skip;
|
|
|
|
// Thread count is read from /proc/self/task, which only exists on Linux.
|
|
itOnLinux("should keep the thread count constant while receiving", async () => {
|
|
const ctrl = await core.chatMigrateInit(dbPath, "key", core.MigrationConfirmation.YesUp);
|
|
for (let i = 0; i < 10; i++) await core.chatRecvMsgWait(ctrl, 1);
|
|
const threadCount = () => fs.readdirSync("/proc/self/task").length;
|
|
const warmCount = threadCount();
|
|
const counts = new Set<number>();
|
|
for (let i = 0; i < 200; i++) {
|
|
await core.chatRecvMsgWait(ctrl, 1);
|
|
counts.add(threadCount());
|
|
}
|
|
expect({warmCount, counts: [...counts]}).toEqual({warmCount, counts: [warmCount]});
|
|
await stopAndClose(ctrl);
|
|
}, 30000);
|
|
|
|
it("should receive on two controllers concurrently", async () => {
|
|
const dbPathB = path.join(tmpDir, "simplex_b");
|
|
copyTemplateDb(dbPathB);
|
|
const ctrlA = await core.chatMigrateInit(dbPath, "key", core.MigrationConfirmation.YesUp);
|
|
const ctrlB = await core.chatMigrateInit(dbPathB, "key", core.MigrationConfirmation.YesUp);
|
|
const start = Date.now();
|
|
await expect(Promise.all([core.chatRecvMsgWait(ctrlA, 1_000_000), core.chatRecvMsgWait(ctrlB, 1_000_000)]))
|
|
.resolves.toEqual([undefined, undefined]);
|
|
const elapsed = Date.now() - start;
|
|
expect(elapsed).toBeGreaterThanOrEqual(900);
|
|
expect(elapsed).toBeLessThan(1800);
|
|
await stopAndClose(ctrlA);
|
|
await stopAndClose(ctrlB);
|
|
});
|
|
|
|
it("should receive events of one controller in order", async () => {
|
|
const ctrl = await core.chatMigrateInit(dbPath, "key", core.MigrationConfirmation.YesUp);
|
|
for (const action of ["first", "second"]) {
|
|
await expect(core.chatSendCmd(ctrl, `/debug event {"type": "timedAction", "action": "${action}", "durationMilliseconds": 1}`))
|
|
.resolves.toMatchObject({type: "cmdOk"});
|
|
}
|
|
const events = await Promise.all([core.chatRecvMsgWait(ctrl, 500_000), core.chatRecvMsgWait(ctrl, 500_000)]);
|
|
expect(events).toMatchObject([
|
|
{type: "timedAction", action: "first"},
|
|
{type: "timedAction", action: "second"}
|
|
]);
|
|
await stopAndClose(ctrl);
|
|
});
|
|
|
|
it("should close the store while a receive is in flight", async () => {
|
|
const ctrl = await core.chatMigrateInit(dbPath, "key", core.MigrationConfirmation.YesUp);
|
|
// starts the receiver thread, so the next request only has to wake it
|
|
await core.chatRecvMsgWait(ctrl, 1);
|
|
const settle = (p: Promise<unknown>) => p.then((event) => ({event}), (e: Error) => ({error: e.message}));
|
|
const receives = Promise.all([settle(core.chatRecvMsgWait(ctrl, 3_000_000)), settle(core.chatRecvMsgWait(ctrl, 3_000_000))]);
|
|
// the thread enters the first receive within this margin even under load, so the second one is still queued at close
|
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
await expect(core.chatSendCmd(ctrl, "/_stop")).resolves.toMatchObject({type: "chatStopped"});
|
|
let closed = false;
|
|
const close = core.chatCloseStore(ctrl).then(() => { closed = true; });
|
|
const timerStart = Date.now();
|
|
const timerDelay = await new Promise<number>((resolve) => setTimeout(() => resolve(Date.now() - timerStart), 10));
|
|
expect({closed, timerDelayBelow100ms: timerDelay < 100}).toEqual({closed: false, timerDelayBelow100ms: true});
|
|
await close;
|
|
expect(await receives).toEqual([{event: undefined}, {error: "chat receiver stopped"}]);
|
|
});
|
|
|
|
it("should accept loading libsimplex again from the same path", () => {
|
|
expect(() => simplex.load(addonPath, libPath)).not.toThrow();
|
|
});
|
|
|
|
it("should refuse to load libsimplex from another path", () => {
|
|
expect(() => simplex.load(addonPath, path.resolve("other", "libsimplex.so"))).toThrow(`libsimplex already loaded from ${libPath}`);
|
|
});
|
|
|
|
it("should load libsimplex after a failed load and in several workers", () => {
|
|
const addon = path.resolve(__dirname, "..", "build", "Release", "simplex.node");
|
|
const childDbPath = path.resolve(tmpDir, "simplex_workers");
|
|
const script = `
|
|
const {Worker} = require("worker_threads");
|
|
const simplex = require(${JSON.stringify(addon)});
|
|
try { simplex.load(${JSON.stringify(addon)}) } catch (e) { console.log(JSON.stringify(e.message.endsWith("does not export hs_init_with_rtsopts"))) }
|
|
const load = ${JSON.stringify(`require(${JSON.stringify(addon)}).load(${JSON.stringify(libPath)})`)};
|
|
const workers = Array.from({length: 4}, () => new Worker(load, {eval: true}));
|
|
Promise.all(workers.map((w) => new Promise((resolve, reject) => w.on("exit", resolve).on("error", reject))))
|
|
.then(async (codes) => {
|
|
simplex.load(${JSON.stringify(libPath)});
|
|
const [ctrl] = await simplex.chat_migrate_init(${JSON.stringify(childDbPath)}, "key", "yesUp");
|
|
await simplex.chat_send_cmd(ctrl, "/_stop");
|
|
console.log(JSON.stringify({codes, closed: await simplex.chat_close_store(ctrl) === ""}));
|
|
});
|
|
`;
|
|
const child = spawnSync(process.execPath, ["-e", script], {timeout: 30000, encoding: "utf8"});
|
|
if (child.status !== 0 || child.signal !== null) console.log("child stderr:", child.stderr);
|
|
expect({status: child.status, signal: child.signal, stdout: child.stdout.trim()})
|
|
.toEqual({status: 0, signal: null, stdout: 'true\n{"codes":[0,0,0,0],"closed":true}'});
|
|
}, 35000);
|
|
|
|
it("should let the process exit while a receiver is idle", () => {
|
|
const childDbPath = path.resolve(dbPath);
|
|
const script = `
|
|
const simplex = require("./build/Release/simplex.node");
|
|
simplex.load(${JSON.stringify(libPath)});
|
|
simplex.chat_migrate_init(${JSON.stringify(childDbPath)}, "key", "yesUp")
|
|
.then(([ctrl]) => simplex.chat_recv_msg_wait(ctrl, 1))
|
|
.then((res) => console.log("received " + JSON.stringify(res)));
|
|
`;
|
|
const child = spawnSync(process.execPath, ["-e", script], {cwd: path.join(__dirname, ".."), timeout: 25000, encoding: "utf8"});
|
|
if (child.status !== 0 || child.signal !== null) console.log("child stderr:", child.stderr);
|
|
expect({status: child.status, signal: child.signal, stdout: child.stdout.trim()})
|
|
.toEqual({status: 0, signal: null, stdout: 'received ""'});
|
|
});
|
|
|
|
it("should not crash when closing stopped controllers repeatedly", async () => {
|
|
const script = `
|
|
const fs = require("fs"), path = require("path");
|
|
const simplex = require("./build/Release/simplex.node");
|
|
simplex.load(${JSON.stringify(libPath)});
|
|
(async () => {
|
|
// a new database runs every migration, each committed with a disk flush that takes up to 30 ms on CI runners
|
|
const dir = fs.mkdtempSync(path.join(${JSON.stringify(path.resolve(tmpDir))}, "close-"));
|
|
for (let i = 0; i < 40; i++) {
|
|
const [ctrl] = await simplex.chat_migrate_init(path.join(dir, "simplex"), "key", "yesUp");
|
|
await simplex.chat_send_cmd(ctrl, "/v");
|
|
await simplex.chat_send_cmd(ctrl, "/_stop");
|
|
const res = await simplex.chat_close_store(ctrl);
|
|
if (res !== "") throw new Error("close failed: " + res);
|
|
}
|
|
fs.rmSync(dir, {recursive: true, force: true});
|
|
})();
|
|
`;
|
|
const runChild = () => new Promise<{code: number | null, signal: NodeJS.Signals | null, stderr: string}>((resolve) => {
|
|
const child = execFile(process.execPath, ["-e", script], {cwd: path.join(__dirname, ".."), timeout: 150000}, (_error, _stdout, stderr) =>
|
|
resolve({code: child.exitCode, signal: child.signalCode, stderr}));
|
|
});
|
|
const childCount = 3;
|
|
const results = await Promise.all(Array.from({length: childCount}, runChild));
|
|
for (const r of results) if (r.code !== 0 || r.signal !== null) console.log("child stderr:", r.stderr);
|
|
expect(results.map(({code, signal}) => ({code, signal}))).toEqual(Array(childCount).fill({code: 0, signal: null}));
|
|
}, 180000);
|
|
});
|