Skip to content

Commit d092243

Browse files
authored
Python: Add autogate to call abortIsolate on a fatal error (#6708)
1 parent 63671f3 commit d092243

16 files changed

Lines changed: 176 additions & 19 deletions

File tree

src/cloudflare/internal/workers.d.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,5 +62,9 @@ export interface CacheContext {
6262

6363
export function getCtxCache(): CacheContext | undefined;
6464

65-
// Only defined when the `workerd_experimental` compat flag is enabled.
6665
export function abortIsolate(reason?: string): never;
66+
67+
// True when the workerd_experimental compat flag is enabled. Use this for gating experimental
68+
// re-exports in user-facing wrappers; Cloudflare.compatibilityFlags filters out experimental
69+
// flags themselves so it cannot be used to detect this.
70+
export const isExperimental: boolean;

src/cloudflare/workers.ts

Lines changed: 9 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -439,18 +439,12 @@ export const cache = new Proxy(
439439

440440
export const tracing = innerTracing;
441441

442-
// `abortIsolate` is only defined on `entrypoints` when the
443-
// `workerd_experimental` compat flag is enabled. When the flag is disabled,
444-
// calling it throws a clear error rather than a cryptic
445-
// "undefined is not a function".
446-
const rawAbortIsolate: ((reason?: string) => never) | undefined = (
447-
entrypoints as { abortIsolate?: (reason?: string) => never }
448-
).abortIsolate;
449-
export const abortIsolate: (reason?: string) => never =
450-
rawAbortIsolate !== undefined
451-
? rawAbortIsolate.bind(entrypoints)
452-
: (_reason?: string): never => {
453-
throw new Error(
454-
'abortIsolate() requires the "experimental" compatibility flag.'
455-
);
456-
};
442+
export function abortIsolate(reason?: string): never {
443+
if (entrypoints.isExperimental) {
444+
entrypoints.abortIsolate(reason);
445+
} else {
446+
throw new Error(
447+
'abortIsolate() requires the "experimental" compatibility flag.'
448+
);
449+
}
450+
}

src/pyodide/internal/metadata.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import { default as ArtifactBundler } from 'pyodide-internal:artifacts';
77

88
export const IS_WORKERD = MetadataReader.isWorkerd();
99
export const IS_TRACING = MetadataReader.isTracing();
10+
export const SHOULD_ABORT_ISOLATE_ON_FATAL_ERROR =
11+
MetadataReader.shouldAbortIsolateOnFatalError();
1012

1113
// Snapshots
1214
export const SHOULD_SNAPSHOT_TO_DISK = MetadataReader.shouldSnapshotToDisk();

src/pyodide/internal/python.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,11 @@ import {
2424
import {
2525
LEGACY_VENDOR_PATH,
2626
PROCESS_PTH_FILES,
27+
SHOULD_ABORT_ISOLATE_ON_FATAL_ERROR,
2728
setCpuLimitNearlyExceededCallback,
2829
} from 'pyodide-internal:metadata';
2930
import { default as FatalReporter } from 'pyodide-internal:fatal-reporter';
31+
import { default as cloudflareWorkers } from 'cloudflare-internal:workers';
3032

3133
import { default as UnsafeEval } from 'internal:unsafe-eval';
3234
import {
@@ -283,6 +285,11 @@ export async function loadPyodide(
283285
} catch (_e) {
284286
FatalReporter.reportFatal('Internal error reporting fatal error');
285287
}
288+
if (SHOULD_ABORT_ISOLATE_ON_FATAL_ERROR) {
289+
cloudflareWorkers.abortIsolate(
290+
`Python worker fatal error: ${String(error)}`
291+
);
292+
}
286293
};
287294
return pyodide;
288295
} catch (e) {
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
// Copyright (c) 2026 Cloudflare, Inc.
2+
// Licensed under the Apache 2.0 license found in the LICENSE file or at:
3+
// https://opensource.org/licenses/Apache-2.0
4+
5+
declare const cloudflareWorkers: {
6+
abortIsolate: (reason?: string) => never;
7+
};
8+
9+
export default cloudflareWorkers;

src/pyodide/types/runtime-generated/metadata.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ declare namespace MetadataReader {
1919
const isTracing: () => boolean;
2020
const shouldSnapshotToDisk: () => boolean;
2121
const isCreatingBaselineSnapshot: () => boolean;
22+
const shouldAbortIsolateOnFatalError: () => boolean;
2223
const getRequirements: () => string[];
2324
const getMainModule: () => string;
2425
const hasMemorySnapshot: () => boolean;

src/workerd/api/pyodide/pyodide.c++

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
#include <workerd/io/compatibility-date.h>
99
#include <workerd/io/features.h>
1010
#include <workerd/io/io-context.h>
11+
#include <workerd/util/autogate.h>
1112
#include <workerd/util/strings.h>
1213

1314
#include <pyodide/generated/pyodide_extra.capnp.h>
@@ -415,6 +416,10 @@ kj::Array<kj::StringPtr> PyodideMetadataReader::getBaselineSnapshotImports() {
415416
return kj::heapArray(snapshotImports.begin(), snapshotImports.size());
416417
}
417418

419+
bool PyodideMetadataReader::shouldAbortIsolateOnFatalError() {
420+
return util::Autogate::isEnabled(util::AutogateKey::PYTHON_ABORT_ISOLATE_ON_FATAL_ERROR);
421+
}
422+
418423
jsg::JsObject PyodideMetadataReader::getCompatibilityFlags(jsg::Lock& js) {
419424
auto flags = FeatureFlags::get(js);
420425
auto obj = js.objNoProto();

src/workerd/api/pyodide/pyodide.h

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,10 @@ class PyodideMetadataReader: public jsg::Object {
189189
return state->createBaselineSnapshot;
190190
}
191191

192+
// Returns whether the python-abort-isolate-on-fatal-error autogate is enabled. When true, the
193+
// Python on_fatal handler should call abortIsolate() to terminate the isolate after reporting.
194+
bool shouldAbortIsolateOnFatalError();
195+
192196
kj::StringPtr getMainModule() {
193197
return state->mainModule;
194198
}
@@ -268,6 +272,7 @@ class PyodideMetadataReader: public jsg::Object {
268272
JSG_METHOD(getPackagesVersion);
269273
JSG_METHOD(getPackagesLock);
270274
JSG_METHOD(isCreatingBaselineSnapshot);
275+
JSG_METHOD(shouldAbortIsolateOnFatalError);
271276
JSG_METHOD(getTransitiveRequirements);
272277
JSG_METHOD(getCompatibilityFlags);
273278
JSG_STATIC_METHOD(getBaselineSnapshotImports);

src/workerd/api/workers-module.c++

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
#include <workerd/api/actor-state.h>
88
#include <workerd/api/global-scope.h>
9+
#include <workerd/io/features.h>
910

1011
namespace workerd::api {
1112

@@ -76,4 +77,8 @@ void EntrypointsModule::abortIsolate(jsg::Lock& js, jsg::Optional<kj::String> re
7677
js.terminateExecutionNow();
7778
}
7879

80+
bool EntrypointsModule::getIsExperimental(jsg::Lock& js) {
81+
return FeatureFlags::get(js).getWorkerdExperimental();
82+
}
83+
7984
} // namespace workerd::api

src/workerd/api/workers-module.h

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,11 @@ class EntrypointsModule: public jsg::Object {
9090
// process.
9191
void abortIsolate(jsg::Lock& js, jsg::Optional<kj::String> reason);
9292

93+
// Returns whether the workerd_experimental compat flag is enabled. Exposed on the internal
94+
// module so user-facing wrappers in cloudflare:workers can gate experimental APIs without
95+
// relying on Cloudflare.compatibilityFlags (which filters out experimental flags themselves).
96+
bool getIsExperimental(jsg::Lock& js);
97+
9398
JSG_RESOURCE_TYPE(EntrypointsModule, CompatibilityFlags::Reader flags) {
9499
JSG_NESTED_TYPE(WorkerEntrypoint);
95100
JSG_NESTED_TYPE(WorkflowEntrypoint);
@@ -103,9 +108,18 @@ class EntrypointsModule: public jsg::Object {
103108
JSG_METHOD(waitUntil);
104109
JSG_METHOD(getCtxCache);
105110

106-
if (flags.getWorkerdExperimental()) {
107-
JSG_METHOD(abortIsolate);
108-
}
111+
// abortIsolate:
112+
//
113+
// From user code only usable with experimental set for now.
114+
// The Python runtime wants to use it directly.
115+
//
116+
// So we always expose it to internal JS for the Python runtime, but the
117+
// version exposed to user code checks this isExperimental flag and throws
118+
// if it returns false.
119+
//
120+
// TODO: Clean up when we remove the experimental gate on abortIsolate.
121+
JSG_METHOD(abortIsolate);
122+
JSG_READONLY_PROTOTYPE_PROPERTY(isExperimental, getIsExperimental);
109123
}
110124
};
111125

0 commit comments

Comments
 (0)