@blaxel/core 0.3.13 → 0.3.18-dev.283
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/client/sdk.gen.js +105 -3
- package/dist/cjs/common/settings.js +28 -3
- package/dist/cjs/common/settings.test.js +50 -0
- package/dist/cjs/common/shell.js +18 -0
- package/dist/cjs/sandbox/filesystem/filesystem.js +31 -11
- package/dist/cjs/sandbox/filesystem/filesystem.test.js +207 -2
- package/dist/cjs/sandbox/sandbox.js +124 -0
- package/dist/cjs/types/client/sdk.gen.d.ts +31 -1
- package/dist/cjs/types/client/types.gen.d.ts +368 -7
- package/dist/cjs/types/common/settings.d.ts +15 -0
- package/dist/cjs/types/common/shell.d.ts +13 -0
- package/dist/cjs/types/sandbox/sandbox.d.ts +56 -1
- package/dist/cjs/types/sandbox/schedule.d.ts +2 -2
- package/dist/cjs-browser/.tsbuildinfo +1 -1
- package/dist/cjs-browser/client/sdk.gen.js +105 -3
- package/dist/cjs-browser/common/settings.js +28 -3
- package/dist/cjs-browser/common/settings.test.js +50 -0
- package/dist/cjs-browser/common/shell.js +18 -0
- package/dist/cjs-browser/sandbox/filesystem/filesystem.js +31 -11
- package/dist/cjs-browser/sandbox/filesystem/filesystem.test.js +207 -2
- package/dist/cjs-browser/sandbox/sandbox.js +124 -0
- package/dist/cjs-browser/types/client/sdk.gen.d.ts +31 -1
- package/dist/cjs-browser/types/client/types.gen.d.ts +368 -7
- package/dist/cjs-browser/types/common/settings.d.ts +15 -0
- package/dist/cjs-browser/types/common/shell.d.ts +13 -0
- package/dist/cjs-browser/types/sandbox/sandbox.d.ts +56 -1
- package/dist/cjs-browser/types/sandbox/schedule.d.ts +2 -2
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/client/sdk.gen.js +96 -0
- package/dist/esm/common/settings.js +28 -3
- package/dist/esm/common/settings.test.js +50 -0
- package/dist/esm/common/shell.js +15 -0
- package/dist/esm/sandbox/filesystem/filesystem.js +31 -11
- package/dist/esm/sandbox/filesystem/filesystem.test.js +208 -3
- package/dist/esm/sandbox/sandbox.js +125 -1
- package/dist/esm-browser/.tsbuildinfo +1 -1
- package/dist/esm-browser/client/sdk.gen.js +96 -0
- package/dist/esm-browser/common/settings.js +28 -3
- package/dist/esm-browser/common/settings.test.js +50 -0
- package/dist/esm-browser/common/shell.js +15 -0
- package/dist/esm-browser/sandbox/filesystem/filesystem.js +31 -11
- package/dist/esm-browser/sandbox/filesystem/filesystem.test.js +208 -3
- package/dist/esm-browser/sandbox/sandbox.js +125 -1
- package/package.json +1 -1
|
@@ -1616,6 +1616,22 @@ export const updateSandbox = (options) => {
|
|
|
1616
1616
|
}
|
|
1617
1617
|
});
|
|
1618
1618
|
};
|
|
1619
|
+
/**
|
|
1620
|
+
* Archive sandbox
|
|
1621
|
+
* Archives a sandbox. The changes its filesystem accumulated over its image are exported to the archive store, then the sandbox is shut down while its definition is kept, so it can be recreated later with the same disk. Memory and running processes are not preserved, processes are started again from their configuration when the sandbox is unarchived.
|
|
1622
|
+
*/
|
|
1623
|
+
export const archiveSandbox = (options) => {
|
|
1624
|
+
return (options.client ?? _heyApiClient).post({
|
|
1625
|
+
security: [
|
|
1626
|
+
{
|
|
1627
|
+
scheme: 'bearer',
|
|
1628
|
+
type: 'http'
|
|
1629
|
+
}
|
|
1630
|
+
],
|
|
1631
|
+
url: '/sandboxes/{sandboxName}/archive',
|
|
1632
|
+
...options
|
|
1633
|
+
});
|
|
1634
|
+
};
|
|
1619
1635
|
/**
|
|
1620
1636
|
* Fork sandbox
|
|
1621
1637
|
* Forks a sandbox into a new sandbox or application. When forking to a sandbox, the target must not already exist (409 if it does). When forking to an application, a new revision is added if the app already exists, or a new application is created. This is a WIP endpoint — the full implementation depends on the execution plane.
|
|
@@ -1932,6 +1948,38 @@ export const deleteSandboxSnapshot = (options) => {
|
|
|
1932
1948
|
...options
|
|
1933
1949
|
});
|
|
1934
1950
|
};
|
|
1951
|
+
/**
|
|
1952
|
+
* Restore sandbox from snapshot
|
|
1953
|
+
* Restores a sandbox to one of its own snapshots. The running sandbox is torn down and rebuilt from the snapshot under the same name and URLs, so everything it held since the snapshot was taken is lost unless it was itself snapshotted.
|
|
1954
|
+
*/
|
|
1955
|
+
export const restoreSandboxSnapshot = (options) => {
|
|
1956
|
+
return (options.client ?? _heyApiClient).post({
|
|
1957
|
+
security: [
|
|
1958
|
+
{
|
|
1959
|
+
scheme: 'bearer',
|
|
1960
|
+
type: 'http'
|
|
1961
|
+
}
|
|
1962
|
+
],
|
|
1963
|
+
url: '/sandboxes/{sandboxName}/snapshots/{snapshotId}/restore',
|
|
1964
|
+
...options
|
|
1965
|
+
});
|
|
1966
|
+
};
|
|
1967
|
+
/**
|
|
1968
|
+
* Unarchive sandbox
|
|
1969
|
+
* Recreates an archived sandbox from its archive. The recreated sandbox runs the current generation whatever generation it was archived from, restores the archived filesystem before anything starts, and starts the saved processes again with new process identifiers.
|
|
1970
|
+
*/
|
|
1971
|
+
export const unarchiveSandbox = (options) => {
|
|
1972
|
+
return (options.client ?? _heyApiClient).post({
|
|
1973
|
+
security: [
|
|
1974
|
+
{
|
|
1975
|
+
scheme: 'bearer',
|
|
1976
|
+
type: 'http'
|
|
1977
|
+
}
|
|
1978
|
+
],
|
|
1979
|
+
url: '/sandboxes/{sandboxName}/unarchive',
|
|
1980
|
+
...options
|
|
1981
|
+
});
|
|
1982
|
+
};
|
|
1935
1983
|
/**
|
|
1936
1984
|
* Get sandbox by external ID
|
|
1937
1985
|
* Returns the most recent non-terminated sandbox matching the given external ID. If no active sandbox is found, returns 404.
|
|
@@ -1948,6 +1996,54 @@ export const getSandboxByExternalId = (options) => {
|
|
|
1948
1996
|
...options
|
|
1949
1997
|
});
|
|
1950
1998
|
};
|
|
1999
|
+
/**
|
|
2000
|
+
* List Workspace Schedule Executions
|
|
2001
|
+
* Returns schedule execution submissions across the workspace, newest first by default. Status describes submission acceptance, not process completion. since and until are inclusive RFC 3339 bounds on creation time.
|
|
2002
|
+
*/
|
|
2003
|
+
export const listScheduleExecutions = (options) => {
|
|
2004
|
+
return (options?.client ?? _heyApiClient).get({
|
|
2005
|
+
security: [
|
|
2006
|
+
{
|
|
2007
|
+
scheme: 'bearer',
|
|
2008
|
+
type: 'http'
|
|
2009
|
+
}
|
|
2010
|
+
],
|
|
2011
|
+
url: '/schedule-executions',
|
|
2012
|
+
...options
|
|
2013
|
+
});
|
|
2014
|
+
};
|
|
2015
|
+
/**
|
|
2016
|
+
* List Workspace Schedules
|
|
2017
|
+
* Returns schedule definitions across the workspace, newest first by default.
|
|
2018
|
+
*/
|
|
2019
|
+
export const listSchedules = (options) => {
|
|
2020
|
+
return (options?.client ?? _heyApiClient).get({
|
|
2021
|
+
security: [
|
|
2022
|
+
{
|
|
2023
|
+
scheme: 'bearer',
|
|
2024
|
+
type: 'http'
|
|
2025
|
+
}
|
|
2026
|
+
],
|
|
2027
|
+
url: '/schedules',
|
|
2028
|
+
...options
|
|
2029
|
+
});
|
|
2030
|
+
};
|
|
2031
|
+
/**
|
|
2032
|
+
* Get Sandbox Scheduling Metrics
|
|
2033
|
+
* Returns active sandbox and scheduling metrics for a UTC minute window. since is inclusive and until is exclusive. The default window is the last 24 hours and the maximum is 7 days. Execution status describes submission acceptance, not process completion. Execution totals begin accumulating when metrics collection is enabled and can lag recent firings briefly.
|
|
2034
|
+
*/
|
|
2035
|
+
export const getSandboxScheduleMetrics = (options) => {
|
|
2036
|
+
return (options?.client ?? _heyApiClient).get({
|
|
2037
|
+
security: [
|
|
2038
|
+
{
|
|
2039
|
+
scheme: 'bearer',
|
|
2040
|
+
type: 'http'
|
|
2041
|
+
}
|
|
2042
|
+
],
|
|
2043
|
+
url: '/schedules/metrics',
|
|
2044
|
+
...options
|
|
2045
|
+
});
|
|
2046
|
+
};
|
|
1951
2047
|
/**
|
|
1952
2048
|
* List service accounts
|
|
1953
2049
|
* Returns all service accounts in the workspace. Service accounts are machine identities for external systems to authenticate with Blaxel via OAuth or API keys.
|
|
@@ -24,8 +24,8 @@ function missingCredentialsMessage() {
|
|
|
24
24
|
return "No Blaxel credentials found. Set the BL_API_KEY and BL_WORKSPACE environment variables, or run `bl login`.";
|
|
25
25
|
}
|
|
26
26
|
// Build info - these placeholders are replaced at build time by build:replace-imports
|
|
27
|
-
const BUILD_VERSION = "0.3.
|
|
28
|
-
const BUILD_COMMIT = "
|
|
27
|
+
const BUILD_VERSION = "0.3.18-dev.283";
|
|
28
|
+
const BUILD_COMMIT = "fed607fe45d4a15407e77f7c40f31815d763df5a";
|
|
29
29
|
const BUILD_SENTRY_DSN = "https://fd5e60e1c9820e1eef5ccebb84a07127@o4508714045276160.ingest.us.sentry.io/4510465864564736";
|
|
30
30
|
const BLAXEL_API_VERSION = "2026-04-28";
|
|
31
31
|
// Bun < 1.3.11 never sends connection-level WINDOW_UPDATE: the pooled h2
|
|
@@ -94,6 +94,11 @@ function getOsArch() {
|
|
|
94
94
|
}
|
|
95
95
|
return "browser/unknown";
|
|
96
96
|
}
|
|
97
|
+
/**
|
|
98
|
+
* An integration built on this SDK identifies itself with one User-Agent
|
|
99
|
+
* product token: `<name>/<semver>`, lowercase name.
|
|
100
|
+
*/
|
|
101
|
+
const INTEGRATION_TOKEN_PATTERN = /^[a-z0-9][a-z0-9._-]*\/\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
|
|
97
102
|
class Settings {
|
|
98
103
|
_credentials;
|
|
99
104
|
config;
|
|
@@ -190,14 +195,34 @@ class Settings {
|
|
|
190
195
|
get apiVersion() {
|
|
191
196
|
return env.BL_API_VERSION || BLAXEL_API_VERSION;
|
|
192
197
|
}
|
|
198
|
+
/**
|
|
199
|
+
* Product token identifying the integration built on this SDK, or `""`.
|
|
200
|
+
* Resolved from `config.integration`, then `BL_INTEGRATION`. See `Config.integration`.
|
|
201
|
+
*/
|
|
202
|
+
get integration() {
|
|
203
|
+
const value = this.config.integration ?? env.BL_INTEGRATION ?? "";
|
|
204
|
+
if (!value) {
|
|
205
|
+
return "";
|
|
206
|
+
}
|
|
207
|
+
if (!INTEGRATION_TOKEN_PATTERN.test(value)) {
|
|
208
|
+
logger.warn(`Ignoring invalid Blaxel integration token "${value}": expected <name>/<semver> with a lowercase name`);
|
|
209
|
+
return "";
|
|
210
|
+
}
|
|
211
|
+
return value;
|
|
212
|
+
}
|
|
213
|
+
set integration(value) {
|
|
214
|
+
this.config.integration = value;
|
|
215
|
+
}
|
|
193
216
|
get headers() {
|
|
194
217
|
this.assertCredentials();
|
|
195
218
|
const osArch = getOsArch();
|
|
219
|
+
const sdkUserAgent = `blaxel/sdk/typescript/${this.version} (${osArch}) blaxel/${this.commit}`;
|
|
220
|
+
const integration = this.integration;
|
|
196
221
|
return {
|
|
197
222
|
"x-blaxel-authorization": this.authorization,
|
|
198
223
|
"x-blaxel-workspace": this.workspace || "",
|
|
199
224
|
"Blaxel-Version": this.apiVersion,
|
|
200
|
-
"User-Agent":
|
|
225
|
+
"User-Agent": integration ? `${sdkUserAgent} ${integration}` : sdkUserAgent,
|
|
201
226
|
};
|
|
202
227
|
}
|
|
203
228
|
get name() {
|
|
@@ -216,3 +216,53 @@ describe('Settings H2 tuning knobs (config > env > default)', () => {
|
|
|
216
216
|
});
|
|
217
217
|
});
|
|
218
218
|
});
|
|
219
|
+
describe('Settings.integration', () => {
|
|
220
|
+
const sdkUserAgent = /^blaxel\/sdk\/typescript\/\S+ \([^)]+\) blaxel\/\S+/;
|
|
221
|
+
async function authenticated() {
|
|
222
|
+
const { settings } = await import('./settings.js');
|
|
223
|
+
settings.credentials = new ApiKey({ apiKey: 'test-key', workspace: 'test-ws' });
|
|
224
|
+
delete settings.config.integration;
|
|
225
|
+
delete process.env.BL_INTEGRATION;
|
|
226
|
+
return settings;
|
|
227
|
+
}
|
|
228
|
+
afterEach(async () => {
|
|
229
|
+
const { settings } = await import('./settings.js');
|
|
230
|
+
delete settings.config.integration;
|
|
231
|
+
delete process.env.BL_INTEGRATION;
|
|
232
|
+
});
|
|
233
|
+
it('sends the plain SDK User-Agent by default', async () => {
|
|
234
|
+
const settings = await authenticated();
|
|
235
|
+
expect(settings.integration).toBe('');
|
|
236
|
+
expect(settings.headers['User-Agent']).toMatch(new RegExp(sdkUserAgent.source + '$'));
|
|
237
|
+
});
|
|
238
|
+
it('appends the token set programmatically, once', async () => {
|
|
239
|
+
const settings = await authenticated();
|
|
240
|
+
settings.integration = 'deepseek-harness-blaxel-sandbox/0.1.2';
|
|
241
|
+
const userAgent = settings.headers['User-Agent'];
|
|
242
|
+
expect(userAgent).toMatch(sdkUserAgent);
|
|
243
|
+
expect(userAgent.endsWith(' deepseek-harness-blaxel-sandbox/0.1.2')).toBe(true);
|
|
244
|
+
expect(userAgent.split('deepseek-harness-blaxel-sandbox').length).toBe(2);
|
|
245
|
+
});
|
|
246
|
+
it('reads BL_INTEGRATION when nothing is set programmatically, and config wins', async () => {
|
|
247
|
+
const settings = await authenticated();
|
|
248
|
+
process.env.BL_INTEGRATION = 'herdr/1.4.0-rc.1';
|
|
249
|
+
expect(settings.headers['User-Agent'].endsWith(' herdr/1.4.0-rc.1')).toBe(true);
|
|
250
|
+
settings.setConfig({ ...settings.config, integration: 'deepseek-harness-blaxel-sandbox/0.1.2' });
|
|
251
|
+
expect(settings.headers['User-Agent'].endsWith(' deepseek-harness-blaxel-sandbox/0.1.2')).toBe(true);
|
|
252
|
+
});
|
|
253
|
+
it.each(['Deepseek/0.1.2', 'deepseek-harness', 'deepseek/0.1', 'a/1.0.0 b/1.0.0', 'deepseek/0.1.2 (extra)', ' x/1.0.0'])('ignores the invalid token %j', async (value) => {
|
|
254
|
+
const settings = await authenticated();
|
|
255
|
+
settings.integration = value;
|
|
256
|
+
expect(settings.integration).toBe('');
|
|
257
|
+
expect(settings.headers['User-Agent']).toMatch(new RegExp(sdkUserAgent.source + '$'));
|
|
258
|
+
});
|
|
259
|
+
it('changes no header other than User-Agent', async () => {
|
|
260
|
+
const settings = await authenticated();
|
|
261
|
+
const plain = { ...settings.headers };
|
|
262
|
+
settings.integration = 'deepseek-harness-blaxel-sandbox/0.1.2';
|
|
263
|
+
const tagged = { ...settings.headers };
|
|
264
|
+
delete plain['User-Agent'];
|
|
265
|
+
delete tagged['User-Agent'];
|
|
266
|
+
expect(tagged).toEqual(plain);
|
|
267
|
+
});
|
|
268
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quote a string so a POSIX shell reads it as one literal argument.
|
|
3
|
+
*
|
|
4
|
+
* The sandbox process API accepts a single command string, which the server
|
|
5
|
+
* runs as `sh -c <command>`. Any value interpolated into that string is parsed
|
|
6
|
+
* by the shell, so an unquoted path containing `;`, `|`, `&&`, `$()`, backticks
|
|
7
|
+
* or `>` would execute as its own command. Single quotes suppress every form of
|
|
8
|
+
* shell expansion; the only character they cannot contain is a single quote
|
|
9
|
+
* itself, which is closed, escaped, and reopened.
|
|
10
|
+
*
|
|
11
|
+
* This is the TypeScript equivalent of Python's `shlex.quote`.
|
|
12
|
+
*/
|
|
13
|
+
export function shellQuote(value) {
|
|
14
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
15
|
+
}
|
|
@@ -2,12 +2,13 @@ import { fs } from "../../common/node.js";
|
|
|
2
2
|
import { settings } from "../../common/settings.js";
|
|
3
3
|
import { withUploadSlot } from "../../common/h2fetch.js";
|
|
4
4
|
import { isTransientResetError, retryOnTransientReset } from "../../common/transient-retry.js";
|
|
5
|
+
import { shellQuote } from "../../common/shell.js";
|
|
5
6
|
import { SandboxAction } from "../action.js";
|
|
6
7
|
import { deleteFilesystemByPath, deleteFilesystemMultipartByUploadIdAbort, getFilesystemByPath, getFilesystemContentSearchByPath, getFilesystemFindByPath, getFilesystemSearchByPath, getWatchFilesystemByPath, postFilesystemMultipartByUploadIdComplete, postFilesystemMultipartInitiateByPath, putFilesystemByPath, putFilesystemMultipartByUploadIdPart } from "../client/index.js";
|
|
7
8
|
// Multipart upload constants
|
|
8
9
|
const MULTIPART_THRESHOLD = 5 * 1024 * 1024; // 5MB
|
|
9
10
|
const CHUNK_SIZE = 5 * 1024 * 1024; // 5MB per part
|
|
10
|
-
const MAX_PARALLEL_UPLOADS = 3; //
|
|
11
|
+
const MAX_PARALLEL_UPLOADS = 3; // Maximum workers when the H2 upload cap is disabled
|
|
11
12
|
// The transient-reset classifier and retry loop live in
|
|
12
13
|
// common/transient-retry.ts, shared with the idempotent sandbox-op retry
|
|
13
14
|
// (read/list/etc.) so every path judges "transient" identically. These aliases
|
|
@@ -343,8 +344,10 @@ export class SandboxFileSystem extends SandboxAction {
|
|
|
343
344
|
});
|
|
344
345
|
}
|
|
345
346
|
async cp(source, destination, { maxWait = 180000 } = {}) {
|
|
347
|
+
// Quote both paths so the shell that runs this command treats them as
|
|
348
|
+
// single literal arguments instead of interpreting metacharacters in them.
|
|
346
349
|
let process = await this.process.exec({
|
|
347
|
-
command: `cp -r ${source} ${destination}`,
|
|
350
|
+
command: `cp -r ${shellQuote(source)} ${shellQuote(destination)}`,
|
|
348
351
|
});
|
|
349
352
|
process = await this.process.wait(process.pid, { maxWait, interval: 100 });
|
|
350
353
|
if (process.status === "failed") {
|
|
@@ -498,11 +501,19 @@ export class SandboxFileSystem extends SandboxAction {
|
|
|
498
501
|
// no h2Domain the parts go over globalThis.fetch on separate connections,
|
|
499
502
|
// so the shared-connection cap does not apply.
|
|
500
503
|
const h2Domain = this.sandbox?.h2Domain;
|
|
501
|
-
//
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
504
|
+
// Keep a bounded worker queue full instead of waiting at fixed batch
|
|
505
|
+
// boundaries. The upload gate remains authoritative across files; its
|
|
506
|
+
// default of 2 also bounds this queue on H2.
|
|
507
|
+
const h2UploadLimit = h2Domain ? settings.maxConcurrentUploadH2Requests : 0;
|
|
508
|
+
const workerCount = Math.min(numParts, h2UploadLimit > 0 ? Math.min(MAX_PARALLEL_UPLOADS, h2UploadLimit) : MAX_PARALLEL_UPLOADS);
|
|
509
|
+
let nextPartNumber = 1;
|
|
510
|
+
let stopped = false;
|
|
511
|
+
let uploadError;
|
|
512
|
+
const uploadNextPart = async () => {
|
|
513
|
+
while (!stopped) {
|
|
514
|
+
const partNumber = nextPartNumber++;
|
|
515
|
+
if (partNumber > numParts)
|
|
516
|
+
return;
|
|
506
517
|
const start = (partNumber - 1) * CHUNK_SIZE;
|
|
507
518
|
const end = Math.min(start + CHUNK_SIZE, size);
|
|
508
519
|
const chunk = blob.slice(start, end);
|
|
@@ -510,11 +521,20 @@ export class SandboxFileSystem extends SandboxAction {
|
|
|
510
521
|
// concurrent part streams, not retry sequences; freed during backoff.
|
|
511
522
|
const doPart = () => this.uploadPart(uploadId, partNumber, chunk);
|
|
512
523
|
const partWithSlot = h2Domain ? () => withUploadSlot(h2Domain, doPart) : doPart;
|
|
513
|
-
|
|
524
|
+
try {
|
|
525
|
+
parts.push(await retryOnTransient(partWithSlot));
|
|
526
|
+
}
|
|
527
|
+
catch (error) {
|
|
528
|
+
if (!stopped) {
|
|
529
|
+
stopped = true;
|
|
530
|
+
uploadError = error;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
514
533
|
}
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
534
|
+
};
|
|
535
|
+
await Promise.all(Array.from({ length: workerCount }, () => uploadNextPart()));
|
|
536
|
+
if (stopped) {
|
|
537
|
+
throw uploadError;
|
|
518
538
|
}
|
|
519
539
|
// Sort parts by partNumber to ensure correct order
|
|
520
540
|
parts.sort((a, b) => (a.partNumber ?? 0) - (b.partNumber ?? 0));
|
|
@@ -1,9 +1,61 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { settings } from "../../common/settings.js";
|
|
2
3
|
import { SandboxFileSystem } from "./filesystem.js";
|
|
3
4
|
const waitForPartUpload = () => new Promise((resolve) => setTimeout(resolve, 5));
|
|
5
|
+
function deferred() {
|
|
6
|
+
let resolve;
|
|
7
|
+
const promise = new Promise((resolvePromise) => {
|
|
8
|
+
resolve = resolvePromise;
|
|
9
|
+
});
|
|
10
|
+
return { promise, resolve };
|
|
11
|
+
}
|
|
4
12
|
describe("SandboxFileSystem multipart upload", () => {
|
|
5
|
-
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
delete settings.config.maxConcurrentUploadH2Requests;
|
|
15
|
+
delete settings.config.fsPartRetries;
|
|
16
|
+
vi.useRealTimers();
|
|
17
|
+
vi.restoreAllMocks();
|
|
18
|
+
});
|
|
19
|
+
it("keeps an H2 upload slot busy across former batch boundaries for 100 MiB", async () => {
|
|
20
|
+
settings.config.maxConcurrentUploadH2Requests = 2;
|
|
21
|
+
const filesystem = Object.create(SandboxFileSystem.prototype);
|
|
22
|
+
filesystem.sandbox = { h2Domain: "eng-3585.test" };
|
|
23
|
+
const started = [deferred(), deferred(), deferred(), deferred()];
|
|
24
|
+
const release = [deferred(), deferred(), deferred(), deferred()];
|
|
25
|
+
let completedParts = [];
|
|
26
|
+
filesystem.initiateMultipartUpload = () => Promise.resolve({ uploadId: "upload-1" });
|
|
27
|
+
filesystem.uploadPart = async (_uploadId, partNumber) => {
|
|
28
|
+
if (partNumber <= 4) {
|
|
29
|
+
started[partNumber - 1].resolve();
|
|
30
|
+
await release[partNumber - 1].promise;
|
|
31
|
+
}
|
|
32
|
+
return { partNumber, etag: `etag-${partNumber}` };
|
|
33
|
+
};
|
|
34
|
+
filesystem.completeMultipartUpload = (_uploadId, parts) => {
|
|
35
|
+
completedParts = parts;
|
|
36
|
+
return Promise.resolve({ message: "ok" });
|
|
37
|
+
};
|
|
38
|
+
filesystem.abortMultipartUpload = () => Promise.resolve({ message: "aborted" });
|
|
39
|
+
const upload = filesystem.uploadWithMultipart("/tmp/large-file.bin", { size: 100 * 1024 * 1024, slice: () => ({}) });
|
|
40
|
+
await Promise.all([started[0].promise, started[1].promise]);
|
|
41
|
+
release[0].resolve();
|
|
42
|
+
await started[2].promise;
|
|
43
|
+
release[2].resolve();
|
|
44
|
+
const crossedBatchBoundary = await Promise.race([
|
|
45
|
+
started[3].promise.then(() => true),
|
|
46
|
+
new Promise((resolve) => setTimeout(() => resolve(false), 25)),
|
|
47
|
+
]);
|
|
48
|
+
release[1].resolve();
|
|
49
|
+
await started[3].promise;
|
|
50
|
+
release[3].resolve();
|
|
51
|
+
await upload;
|
|
52
|
+
expect(crossedBatchBoundary).toBe(true);
|
|
53
|
+
expect(completedParts).toHaveLength(20);
|
|
54
|
+
});
|
|
55
|
+
it("limits concurrent H2 part uploads to the existing cap", async () => {
|
|
56
|
+
settings.config.maxConcurrentUploadH2Requests = 2;
|
|
6
57
|
const filesystem = Object.create(SandboxFileSystem.prototype);
|
|
58
|
+
filesystem.sandbox = { h2Domain: "concurrency.test" };
|
|
7
59
|
let inFlight = 0;
|
|
8
60
|
let maxInFlight = 0;
|
|
9
61
|
const uploadedParts = [];
|
|
@@ -24,8 +76,161 @@ describe("SandboxFileSystem multipart upload", () => {
|
|
|
24
76
|
filesystem.abortMultipartUpload = () => Promise.resolve({ message: "aborted" });
|
|
25
77
|
const blob = new Blob([new Uint8Array(16 * 1024 * 1024)]);
|
|
26
78
|
await filesystem.uploadWithMultipart("/tmp/large-file.bin", blob);
|
|
27
|
-
expect(maxInFlight).
|
|
79
|
+
expect(maxInFlight).toBe(2);
|
|
28
80
|
expect(uploadedParts.sort((a, b) => a - b)).toEqual([1, 2, 3, 4]);
|
|
29
81
|
expect(completedParts.map((part) => part.partNumber)).toEqual([1, 2, 3, 4]);
|
|
30
82
|
});
|
|
83
|
+
it("uploads every byte exactly once across chunk boundaries", async () => {
|
|
84
|
+
const filesystem = Object.create(SandboxFileSystem.prototype);
|
|
85
|
+
const partSize = 5 * 1024 * 1024;
|
|
86
|
+
const source = new Uint8Array(partSize + 17);
|
|
87
|
+
for (let index = 0; index < source.length; index++) {
|
|
88
|
+
source[index] = index % 251;
|
|
89
|
+
}
|
|
90
|
+
const uploadedChunks = new Map();
|
|
91
|
+
let completedParts = [];
|
|
92
|
+
filesystem.initiateMultipartUpload = () => Promise.resolve({ uploadId: "upload-bytes" });
|
|
93
|
+
filesystem.uploadPart = async (_uploadId, partNumber, fileBlob) => {
|
|
94
|
+
uploadedChunks.set(partNumber, new Uint8Array(await fileBlob.arrayBuffer()));
|
|
95
|
+
return { partNumber, etag: `etag-${partNumber}` };
|
|
96
|
+
};
|
|
97
|
+
filesystem.completeMultipartUpload = (_uploadId, parts) => {
|
|
98
|
+
completedParts = parts;
|
|
99
|
+
return Promise.resolve({ message: "ok" });
|
|
100
|
+
};
|
|
101
|
+
filesystem.abortMultipartUpload = () => Promise.resolve({ message: "aborted" });
|
|
102
|
+
await filesystem.uploadWithMultipart("/tmp/bytes.bin", new Blob([source]));
|
|
103
|
+
const received = new Uint8Array(source.length);
|
|
104
|
+
let offset = 0;
|
|
105
|
+
for (const partNumber of [1, 2]) {
|
|
106
|
+
const chunk = uploadedChunks.get(partNumber);
|
|
107
|
+
expect(chunk).toBeDefined();
|
|
108
|
+
received.set(chunk, offset);
|
|
109
|
+
offset += chunk.length;
|
|
110
|
+
}
|
|
111
|
+
let mismatch = -1;
|
|
112
|
+
for (let index = 0; index < source.length; index++) {
|
|
113
|
+
if (received[index] !== source[index]) {
|
|
114
|
+
mismatch = index;
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
expect([...uploadedChunks.values()].map((chunk) => chunk.length)).toEqual([partSize, 17]);
|
|
119
|
+
expect(offset).toBe(source.length);
|
|
120
|
+
expect(mismatch).toBe(-1);
|
|
121
|
+
expect(completedParts.map((part) => part.partNumber)).toEqual([1, 2]);
|
|
122
|
+
});
|
|
123
|
+
it("retries a transient part failure without duplicating completed parts", async () => {
|
|
124
|
+
vi.useFakeTimers();
|
|
125
|
+
vi.spyOn(Math, "random").mockReturnValue(0);
|
|
126
|
+
settings.config.fsPartRetries = 1;
|
|
127
|
+
const filesystem = Object.create(SandboxFileSystem.prototype);
|
|
128
|
+
const attempts = new Map();
|
|
129
|
+
let completedParts = [];
|
|
130
|
+
filesystem.initiateMultipartUpload = () => Promise.resolve({ uploadId: "upload-retry" });
|
|
131
|
+
filesystem.uploadPart = (_uploadId, partNumber) => {
|
|
132
|
+
const attempt = (attempts.get(partNumber) ?? 0) + 1;
|
|
133
|
+
attempts.set(partNumber, attempt);
|
|
134
|
+
if (partNumber === 1 && attempt === 1) {
|
|
135
|
+
return Promise.reject(Object.assign(new Error("connection reset"), { code: "ECONNRESET" }));
|
|
136
|
+
}
|
|
137
|
+
return Promise.resolve({ partNumber, etag: `etag-${partNumber}` });
|
|
138
|
+
};
|
|
139
|
+
filesystem.completeMultipartUpload = (_uploadId, parts) => {
|
|
140
|
+
completedParts = parts;
|
|
141
|
+
return Promise.resolve({ message: "ok" });
|
|
142
|
+
};
|
|
143
|
+
filesystem.abortMultipartUpload = () => Promise.resolve({ message: "aborted" });
|
|
144
|
+
const upload = filesystem.uploadWithMultipart("/tmp/retry.bin", new Blob([new Uint8Array(6 * 1024 * 1024)]));
|
|
145
|
+
await vi.runAllTimersAsync();
|
|
146
|
+
await upload;
|
|
147
|
+
expect(Object.fromEntries(attempts)).toEqual({ 1: 2, 2: 1 });
|
|
148
|
+
expect(completedParts.map((part) => part.partNumber)).toEqual([1, 2]);
|
|
149
|
+
});
|
|
150
|
+
it("stops assigning queued parts after a part failure", async () => {
|
|
151
|
+
settings.config.maxConcurrentUploadH2Requests = 2;
|
|
152
|
+
const filesystem = Object.create(SandboxFileSystem.prototype);
|
|
153
|
+
filesystem.sandbox = { h2Domain: "cancellation.test" };
|
|
154
|
+
const releaseFirstPart = deferred();
|
|
155
|
+
const secondPartStarted = deferred();
|
|
156
|
+
const failure = new Error("part 2 failed");
|
|
157
|
+
const startedParts = [];
|
|
158
|
+
const events = [];
|
|
159
|
+
filesystem.initiateMultipartUpload = () => Promise.resolve({ uploadId: "upload-cancel" });
|
|
160
|
+
filesystem.uploadPart = async (_uploadId, partNumber) => {
|
|
161
|
+
startedParts.push(partNumber);
|
|
162
|
+
if (partNumber === 1) {
|
|
163
|
+
await releaseFirstPart.promise;
|
|
164
|
+
events.push("part-1-finished");
|
|
165
|
+
return { partNumber, etag: "etag-1" };
|
|
166
|
+
}
|
|
167
|
+
secondPartStarted.resolve();
|
|
168
|
+
events.push("part-2-failed");
|
|
169
|
+
throw failure;
|
|
170
|
+
};
|
|
171
|
+
filesystem.completeMultipartUpload = () => {
|
|
172
|
+
throw new Error("completion must not run");
|
|
173
|
+
};
|
|
174
|
+
filesystem.abortMultipartUpload = () => {
|
|
175
|
+
events.push("aborted");
|
|
176
|
+
return Promise.resolve({ message: "aborted" });
|
|
177
|
+
};
|
|
178
|
+
const upload = filesystem.uploadWithMultipart("/tmp/cancel.bin", new Blob([new Uint8Array(16 * 1024 * 1024)]));
|
|
179
|
+
await secondPartStarted.promise;
|
|
180
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
181
|
+
releaseFirstPart.resolve();
|
|
182
|
+
await expect(upload).rejects.toBe(failure);
|
|
183
|
+
expect(startedParts).toEqual([1, 2]);
|
|
184
|
+
expect(events).toEqual(["part-2-failed", "part-1-finished", "aborted"]);
|
|
185
|
+
});
|
|
186
|
+
it("keeps sliced part data bounded by the active worker count", async () => {
|
|
187
|
+
settings.config.maxConcurrentUploadH2Requests = 2;
|
|
188
|
+
const filesystem = Object.create(SandboxFileSystem.prototype);
|
|
189
|
+
filesystem.sandbox = { h2Domain: "memory.test" };
|
|
190
|
+
let unsettledSlices = 0;
|
|
191
|
+
let maxUnsettledSlices = 0;
|
|
192
|
+
let totalSlices = 0;
|
|
193
|
+
const fakeBlob = {
|
|
194
|
+
size: 100 * 1024 * 1024,
|
|
195
|
+
slice(start, end) {
|
|
196
|
+
totalSlices += 1;
|
|
197
|
+
unsettledSlices += 1;
|
|
198
|
+
maxUnsettledSlices = Math.max(maxUnsettledSlices, unsettledSlices);
|
|
199
|
+
return { size: end - start };
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
filesystem.initiateMultipartUpload = () => Promise.resolve({ uploadId: "upload-memory" });
|
|
203
|
+
filesystem.uploadPart = async (_uploadId, partNumber) => {
|
|
204
|
+
await waitForPartUpload();
|
|
205
|
+
unsettledSlices -= 1;
|
|
206
|
+
return { partNumber, etag: `etag-${partNumber}` };
|
|
207
|
+
};
|
|
208
|
+
filesystem.completeMultipartUpload = () => Promise.resolve({ message: "ok" });
|
|
209
|
+
filesystem.abortMultipartUpload = () => Promise.resolve({ message: "aborted" });
|
|
210
|
+
await filesystem.uploadWithMultipart("/tmp/100mb.bin", fakeBlob);
|
|
211
|
+
expect(totalSlices).toBe(20);
|
|
212
|
+
expect(maxUnsettledSlices).toBe(2);
|
|
213
|
+
expect(unsettledSlices).toBe(0);
|
|
214
|
+
});
|
|
215
|
+
it("aborts an abandoned multipart upload and preserves the part failure", async () => {
|
|
216
|
+
settings.config.maxConcurrentUploadH2Requests = 1;
|
|
217
|
+
const filesystem = Object.create(SandboxFileSystem.prototype);
|
|
218
|
+
filesystem.sandbox = { h2Domain: "cleanup.test" };
|
|
219
|
+
const partFailure = new Error("part failed");
|
|
220
|
+
const abortFailure = new Error("abort failed");
|
|
221
|
+
const aborts = [];
|
|
222
|
+
const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
223
|
+
filesystem.initiateMultipartUpload = () => Promise.resolve({ uploadId: "upload-cleanup" });
|
|
224
|
+
filesystem.uploadPart = () => Promise.reject(partFailure);
|
|
225
|
+
filesystem.completeMultipartUpload = () => {
|
|
226
|
+
throw new Error("completion must not run");
|
|
227
|
+
};
|
|
228
|
+
filesystem.abortMultipartUpload = (uploadId) => {
|
|
229
|
+
aborts.push(uploadId);
|
|
230
|
+
return Promise.reject(abortFailure);
|
|
231
|
+
};
|
|
232
|
+
await expect(filesystem.uploadWithMultipart("/tmp/cleanup.bin", new Blob([new Uint8Array(6 * 1024 * 1024)]))).rejects.toBe(partFailure);
|
|
233
|
+
expect(aborts).toEqual(["upload-cleanup"]);
|
|
234
|
+
expect(consoleError).toHaveBeenCalledWith("Failed to abort multipart upload:", abortFailure);
|
|
235
|
+
});
|
|
31
236
|
});
|