Keep the global object in type assertions of globalThis

`globalThis as { [KEY]?: State }` is the usual way for several modules
to share process-wide state. globalThis lowers to a checked-dynamic
value, and asserting a checked-dynamic value to a record or array type
took the checked conversion, which builds a new native value. A write
through the asserted value went to that copy, so other modules never
saw it; string-keyed and index-signature views threw "expected object".

- An assertion of the global object (globalThis, global, also through
  `as unknown as`) keeps the checked-dynamic value when the conversion
  would copy, so member reads and writes reach the global object.
- File-scope and local bindings of such an assertion hold the global
  object (collectGlobals, lowerVarDecl), so an exported
  `const state = globalThis as ...` works across modules.
- The divergence scan no longer reports these assertions as SC6001
  width copies.
- Runtime: one global-object box per thread (scr_global_object), which
  globalThis, global and globalThis.globalThis all answer, and direct
  `globalThis[symbol]` accesses use that box's symbol-keyed properties,
  so every spelling of the global object sees one property.

Other checked-dynamic values keep the checked conversion: a native
value held as unknown unwraps by identity there, and a checked-dynamic
tree is copied at the documented boundary.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Malte Ubl
2026-10-19 15:52:52 -07:00
co-authored by Claude Opus 5.5
parent 26946d6b22
commit 6255c9351b
15 changed files with 280 additions and 63 deletions
@@ -0,0 +1,78 @@
/* Type assertions of the global object.
*
* `globalThis as { [KEY]?: State }` is the process-global state idiom: a
* module asserts the global object to a record type and reads and writes
* its members, and every other module that asserts it sees the same
* members. Node evaluates the assertion to the global object itself.
*
* The global object is a checked-dynamic value, and a checked-dynamic value
* asserted to a record or array type takes the checked extraction
* (dynCheck), which builds a new native value. For the global object that
* copy is always wrong: a write through it never reached the global object,
* and a view bound before another module's write never saw it. So an
* assertion of the global object keeps the checked-dynamic value itself: a
* view whose member reads and writes go to the global object, and bindings
* of it hold that object.
*
* Other checked-dynamic operands keep the checked extraction: a native value
* held as `unknown` unwraps by identity there, and a checked-dynamic tree is
* copied at the documented checked-value boundary.
*/
import * as ts from "../ts7/adapter.js";
import type { IrType } from "../../ir/ir.js";
import type { Lowerer } from "./lowerer.js";
import { stdlibGlobalNameOf } from "./surfaces.js";
/** A target whose checked extraction builds a new mutable value. */
export function extractionCopiesReference(lowerer: Lowerer, type: IrType): boolean {
const seen = new Set<string>();
const copies = (t: IrType): boolean => {
if (t.kind === "record" || t.kind === "array") return true;
if (t.kind !== "union" || seen.has(t.unionId)) return false;
seen.add(t.unionId);
return lowerer.unions.get(t.unionId)?.arms.some(copies) ?? false;
};
return copies(type);
}
/** The asserted expression without parentheses, `!` and nested
* assertions (`globalThis as unknown as T`). */
function assertedSource(expr: ts.Expression): ts.Expression {
let e = expr;
while (
ts.isParenthesizedExpression(e) ||
ts.isNonNullExpression(e) ||
ts.isAsExpression(e) ||
ts.isTypeAssertion(e) ||
ts.isSatisfiesExpression(e)
)
e = e.expression;
return e;
}
/** `expr` asserts the global object (`globalThis`, `global`). */
export function assertsGlobalObject(
lowerer: Lowerer,
expr: ts.AsExpression | ts.TypeAssertion,
): boolean {
const name = stdlibGlobalNameOf(lowerer, assertedSource(expr.expression));
return name === "globalThis" || name === "global";
}
/** The assertion a binding initializer is, when it keeps the global object:
* bindings of it hold that object rather than a native value of the
* asserted type. A target with no native layout keeps the object as well
* (the assertion lowers to the checked-dynamic value either way). */
export function keptIdentityAssertion(
lowerer: Lowerer,
initializer: ts.Expression,
): ts.AsExpression | ts.TypeAssertion | undefined {
let e = initializer;
while (ts.isParenthesizedExpression(e)) e = e.expression;
if (!ts.isAsExpression(e) && !ts.isTypeAssertion(e)) return undefined;
if (!assertsGlobalObject(lowerer, e)) return undefined;
const target = lowerer.mapTypeOf(lowerer.checker.getTypeFromTypeNode(e.type));
return !target || target.kind === "dyn" || extractionCopiesReference(lowerer, target)
? e
: undefined;
}
@@ -23,6 +23,7 @@ import {
} from "../../diagnostics/diagnostic.js";
import { isCollationCovered } from "./collation-repertoire.js";
import type { Lowerer } from "./lowerer.js";
import { keptIdentityAssertion } from "./assertion-identity.js";
/** How an identity-sensitive binding is used after a copy could replace it. */
interface IdentityUse {
@@ -217,6 +218,8 @@ export function scanDivergences(
}
for (const { decl, source, type } of castDecls) {
// An assertion of the global object keeps that object: no copy.
if (keptIdentityAssertion(lowerer, decl.initializer!) !== undefined) continue;
const name = (decl.name as ts.Identifier).text;
const scope = enclosingScope(decl);
if (scope === undefined) continue;
@@ -148,6 +148,7 @@ import {
unsupportedDiag,
} from "../../diagnostics/diagnostic.js";
import { PoisonError, dynFallbackType, jsFuncNameOf, neverTaintedJsType, own } from "./lowerer.js";
import { assertsGlobalObject, extractionCopiesReference } from "./assertion-identity.js";
import { lowerCollectionSpread } from "./containers/collection-methods.js";
import { lowerHelperReturnCall } from "./containers/iterator-helpers.js";
import {
@@ -9527,6 +9528,11 @@ export function lowerAsExpression(
return inner;
}
}
// The global object: the checked extraction would build a separate
// value, so the assertion keeps the object and its member operations
// reach it (assertion-identity.ts).
if (extractionCopiesReference(lowerer, target) && assertsGlobalObject(lowerer, expr))
return inner;
if (
target.kind === "void" ||
!canDynCheckTo(
@@ -144,6 +144,7 @@ import { workerTargetPaths } from "../worker-target.js";
import { isNativeProxyInitializer } from "./expressions/native-proxy.js";
import { handleEventReaderPrelude } from "./lower-event-target.js";
import { jsHoldsDynObject } from "./lower-exprs.js";
import { keptIdentityAssertion } from "./assertion-identity.js";
/** One file's declarations, split for collection and init-body lowering. */
export interface FileParts {
@@ -1970,6 +1971,30 @@ export function collectGlobals(
}
continue;
}
// A file-scope binding of an assertion of the global object
// (`const state = globalThis as { [KEY]?: State }`): the binding
// holds that object, so writes through it reach the object other
// modules read (assertion-identity.ts).
const asserted = decl.initializer
? keptIdentityAssertion(lowerer, decl.initializer)
: undefined;
if (nameNode === decl.name && !decl.type && asserted) {
const symbol = lowerer.checker.getSymbolAtLocation(nameNode);
if (symbol && !lowerer.globalsBySymbol.has(symbol)) {
const g: IrGlobal = {
id: `%g.${tag}${nsPrefix}${nameNode.text}`,
name: nameNode.text,
type: DYN,
mutable: isLet,
source: bindingSource(nameNode),
...(!isVarDeclared(decl) ? { tdz: true as const } : {}),
};
lowerer.globalsBySymbol.set(symbol, g);
lowerer.globalsList.push(g);
if (isVarDeclared(decl)) noteVarGlobalEntryInit(lowerer, sf, g);
}
continue;
}
// A JS file-scope evolving ARRAY (`const mustCallChecks = [];`
// — test/common's exit-accounting ledger): the strict type
// (any[]) has no mapping, but the VALUE is the dyn array the
@@ -195,6 +195,7 @@ import {
lowerNativeFunctionAssignment,
lowerStrictEquality,
} from "./lower-exprs.js";
import { keptIdentityAssertion } from "./assertion-identity.js";
import { isNativeProxyInitializer } from "./expressions/native-proxy.js";
import { tryLowerExpression } from "./expressions/try-lower-expression.js";
import { lowerUnionFieldWrite } from "./expressions/union-fields.js";
@@ -4918,9 +4919,16 @@ export function lowerVarDecl(
lowerer.unions.get(inferredObjectType.unionId)?.arms.every(isUnitType))))) &&
!decl.type &&
!hasJsTypeAnnotation(decl);
// The binding of an assertion of the global object holds that object,
// whatever its asserted type (assertion-identity.ts).
const keepsAssertedObject =
init.type.kind === "dyn" &&
!decl.type &&
keptIdentityAssertion(lowerer, decl.initializer) !== undefined;
if (
expandsObject ||
preservesObjectIdentity ||
keepsAssertedObject ||
(isJsSourceFile(decl.getSourceFile()) &&
!decl.type &&
isNativeBuiltinValueInitializer(lowerer, decl.initializer))
@@ -5133,7 +5141,7 @@ export function lowerVarDecl(
? npmStaticOptionalSelfWriteType(lowerer, decl, settledType)
: null;
settledType =
expandsObject || preservesObjectIdentity
expandsObject || preservesObjectIdentity || keepsAssertedObject
? DYN
: lowerer.runtimeOptionalBindingType(decl.name, settledType);
if (optionalSelfWriteType !== null) settledType = optionalSelfWriteType;
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6350-globalthis-assertion-state.ts"
],
"diags": []
}
@@ -0,0 +1,8 @@
{
"order": [
"<repo>/tests/corpus/6352-globalthis-state-modules/boot.ts",
"<repo>/tests/corpus/6352-globalthis-state-modules/state.ts",
"<repo>/tests/corpus/6352-globalthis-state-modules/main.ts"
],
"diags": []
}
+4
View File
@@ -926,6 +926,10 @@ void scr_dyn_key_set(ScrDyn *recv, ScrStr *key, ScrDyn *value);
void scr_dyn_arr_named_put(ScrDyn *arr, const char *key, size_t key_len, ScrDyn *value);
void scr_dyn_key_set_computed(ScrDyn *recv, ScrDyn *key, ScrDyn *value);
void scr_dyn_key_delete(ScrDyn *recv, const ScrStr *key, bool strict);
/* This thread's global object box (borrowed). Every spelling of the global
* object answers it; its symbol-keyed properties are the global object's
* (scr_node_builtin.c). */
ScrDyn *scr_global_object(void);
ScrDyn *scr_dyn_global_symbol_get(ScrSym *key); /* borrowed key; +1 value */
void scr_dyn_global_symbol_set(ScrSym *key, ScrDyn *value); /* both borrowed */
bool scr_dyn_global_symbol_has(ScrSym *key);
+16 -4
View File
@@ -86,6 +86,10 @@ ScrDyn *scr_util_system_error_entries(void) {
static SCR_TL ScrDyn *scr_global_known;
static SCR_TL ScrDyn *scr_global_own;
/* The one box denoting this thread's global object. Symbol-keyed properties
* live on the box (string-keyed ones in scr_global_own), so every spelling
* of the global object answers this box. */
static SCR_TL ScrDyn *scr_global_box;
static SCR_TL bool scr_global_cleanup_registered;
static SCR_TL ScrDyn *(*scr_global_console_get)(void);
static SCR_TL ScrDyn *(*scr_global_fetch_get)(void);
@@ -180,6 +184,8 @@ ScrDyn *scr_process_hrtime_value(void) {
}
static void scr_global_cleanup(void) {
scr_dyn_release(scr_global_box);
scr_global_box = NULL;
scr_dyn_release(scr_global_known);
scr_dyn_release(scr_global_own);
scr_global_known = NULL;
@@ -195,7 +201,7 @@ static ScrDyn *scr_global_get(void *handle, const char *key, size_t length) {
if (own) return scr_dyn_retain(own);
if ((length == 10 && memcmp(key, "globalThis", length) == 0) ||
(length == 6 && memcmp(key, "global", length) == 0))
return scr_dyn_new_handle(&scr_global_known, SCR_DYNH_GLOBAL);
return scr_dyn_retain(scr_global_box);
if (length == 7 && memcmp(key, "console", length) == 0 && scr_global_console_get)
return scr_global_console_get();
if (length == 5 && memcmp(key, "fetch", length) == 0 && scr_global_fetch_get) return scr_global_fetch_get();
@@ -223,26 +229,32 @@ static bool scr_global_set(void *handle, const char *key, size_t length, const S
return true;
}
ScrDyn *scr_global_native_init(ScrArr *known, ScrDyn *(*console_get)(void)) {
ScrDyn *scr_global_object(void) {
static const ScrDynHandleOps ops = {
.cls = "Object", .retain = scr_global_retain, .release = scr_global_release,
.get = scr_global_get, .set = scr_global_set,
};
scr_dyn_handle_install(SCR_DYNH_GLOBAL, &ops);
scr_global_console_get = console_get;
if (!scr_global_known) scr_global_known = scr_dyn_new_obj();
if (!scr_global_own) scr_global_own = scr_dyn_new_obj();
if (!scr_global_box) scr_global_box = scr_dyn_new_handle(&scr_global_known, SCR_DYNH_GLOBAL);
if (!scr_global_cleanup_registered) {
scr_atexit(scr_global_cleanup);
scr_global_cleanup_registered = true;
}
return scr_global_box;
}
ScrDyn *scr_global_native_init(ScrArr *known, ScrDyn *(*console_get)(void)) {
ScrDyn *global = scr_global_object();
scr_global_console_get = console_get;
for (size_t i = 0; i < known->len; i++) {
ScrStr *key = (ScrStr *)scr_arr_get_ref(known, (double)i);
if (!scr_dyn_obj_get(scr_global_known, key->data, key->len))
scr_dyn_obj_set(scr_global_known, key->data, key->len, scr_dyn_new_bool(true));
scr_str_release(key);
}
return scr_dyn_new_handle(&scr_global_known, SCR_DYNH_GLOBAL);
return scr_dyn_retain(global);
}
typedef struct ScrBuiltinModule {
+17 -56
View File
@@ -181,72 +181,33 @@ void scr_assert_eq_sym(ScrSym *a, ScrSym *b, bool negated, bool deep,
scr_str_release(ib);
}
/* Symbol-keyed globals share the symbol unit's link gate. The always-linked
* JSON unit must not depend on optional symbol retain/release functions. */
typedef struct ScrGlobalSymbolProperty {
ScrSym *key;
ScrDyn *value;
struct ScrGlobalSymbolProperty *next;
} ScrGlobalSymbolProperty;
static SCR_TL ScrGlobalSymbolProperty *scr_global_symbols = NULL;
static SCR_TL bool scr_global_symbols_cleanup_registered = false;
static void scr_global_symbols_cleanup(void) {
while (scr_global_symbols) {
ScrGlobalSymbolProperty *entry = scr_global_symbols;
scr_global_symbols = entry->next;
scr_sym_release(entry->key);
scr_dyn_release(entry->value);
free(entry);
}
}
/* Direct `globalThis[symbol]` accesses use the symbol-keyed properties of
* the global object's box (scr_global_object), the storage every other
* spelling of the global object reaches, so all of them see one property. */
ScrDyn *scr_dyn_global_symbol_get(ScrSym *key) {
for (ScrGlobalSymbolProperty *entry = scr_global_symbols; entry; entry = entry->next) {
if (entry->key == key) return scr_dyn_retain(entry->value);
}
return scr_dyn_retain(scr_dyn_undefined());
ScrDyn *k = scr_dyn_new_symbol(key);
ScrDyn *value = scr_dyn_symbol_key_get(scr_global_object(), k, false);
scr_dyn_release(k);
return value;
}
void scr_dyn_global_symbol_set(ScrSym *key, ScrDyn *value) {
for (ScrGlobalSymbolProperty *entry = scr_global_symbols; entry; entry = entry->next) {
if (entry->key != key) continue;
ScrDyn *previous = entry->value;
entry->value = scr_dyn_retain(value);
scr_dyn_release(previous);
return;
}
if (!scr_global_symbols_cleanup_registered) {
scr_atexit(scr_global_symbols_cleanup);
scr_global_symbols_cleanup_registered = true;
}
ScrGlobalSymbolProperty *entry = malloc(sizeof *entry);
if (!entry) scr_trap("scriptc: out of memory\n");
entry->key = scr_sym_retain(key);
entry->value = scr_dyn_retain(value);
entry->next = scr_global_symbols;
scr_global_symbols = entry;
ScrDyn *k = scr_dyn_new_symbol(key);
scr_dyn_symbol_key_set(scr_global_object(), k, value);
scr_dyn_release(k);
}
bool scr_dyn_global_symbol_has(ScrSym *key) {
for (ScrGlobalSymbolProperty *entry = scr_global_symbols; entry; entry = entry->next) {
if (entry->key == key) return true;
}
return false;
ScrDyn *k = scr_dyn_new_symbol(key);
bool present = scr_dyn_has_own_computed(scr_global_object(), k);
scr_dyn_release(k);
return present;
}
void scr_dyn_global_symbol_delete(ScrSym *key) {
ScrGlobalSymbolProperty **slot = &scr_global_symbols;
while (*slot) {
ScrGlobalSymbolProperty *entry = *slot;
if (entry->key != key) { slot = &entry->next; continue; }
*slot = entry->next;
scr_sym_release(entry->key);
scr_dyn_release(entry->value);
free(entry);
return;
}
ScrDyn *k = scr_dyn_new_symbol(key);
scr_dyn_key_delete_computed(scr_global_object(), k, false);
scr_dyn_release(k);
}
/* ── fs.Dirent as a dynamic value ────────────────────────────────────
@@ -0,0 +1,50 @@
// Process-global state on the global object through a type assertion:
// every assertion of globalThis denotes the global object itself, so a
// write through one view is visible through every other view and through
// a view bound before the write.
const KEY = Symbol.for("example.state");
interface State {
n: number;
log: string[];
}
type Holder = { [KEY]?: State; label?: string };
const early = globalThis as Holder;
function get(): State | undefined {
return early[KEY];
}
early[KEY] = { n: 1, log: [] };
console.log(get()?.n, (globalThis as Holder)[KEY]?.n);
function touch(entry: string): void {
const view = globalThis as Holder;
const state = view[KEY] ?? (view[KEY] = { n: 0, log: [] });
state.n++;
state.log.push(entry);
}
touch("a");
touch("b");
console.log(early[KEY]?.n, early[KEY]?.log.join(","), get() === early[KEY]);
// string-keyed members of the same object
(globalThis as Holder).label = "ready";
console.log(early.label, (globalThis as unknown as { label?: string }).label);
// ??= initializes once; delete removes the member for every view
const COUNTER = Symbol.for("example.counter");
type Counters = { [COUNTER]?: { hits: number } };
(globalThis as Counters)[COUNTER] ??= { hits: 0 };
(globalThis as Counters)[COUNTER] ??= { hits: 100 };
const counters = globalThis as Counters;
counters[COUNTER]!.hits += 2;
console.log((globalThis as Counters)[COUNTER]?.hits, COUNTER in globalThis);
delete counters[COUNTER];
console.log((globalThis as Counters)[COUNTER], COUNTER in globalThis);
// an index-signature view
const dict = globalThis as unknown as Record<string, number | undefined>;
dict.exampleTotal = 1;
dict["exampleTotal"] = (dict["exampleTotal"] ?? 0) + 1;
console.log((globalThis as unknown as { exampleTotal?: number }).exampleTotal);
@@ -0,0 +1,14 @@
// The boot module writes the state through its own assertion of globalThis.
const STATE = Symbol.for("example.boot-state");
const PENDING = Symbol.for("example.boot-pending");
interface Early {
startedAt: number;
steps: string[];
}
export function boot(): void {
const view = globalThis as { [STATE]?: Early; [PENDING]?: Promise<string> };
view[STATE] = { startedAt: 42, steps: ["boot"] };
view[PENDING] = Promise.resolve("container-1");
}
@@ -0,0 +1,18 @@
// Boot writes the state; main reads it through a binding exported by
// another module, then takes the pending value out of the global object.
import { boot } from "./boot.ts";
import { PENDING_KEY, STATE_KEY, globalState, readState } from "./state.ts";
console.log(readState() === undefined);
boot();
const state = readState();
console.log(state?.startedAt, state?.steps.join(","));
state?.steps.push("main");
console.log(globalState[STATE_KEY]?.steps.join(","));
async function main(): Promise<void> {
const pending = globalState[PENDING_KEY];
globalState[PENDING_KEY] = undefined;
console.log(await pending, globalState[PENDING_KEY]);
}
void main();
@@ -0,0 +1,18 @@
// Shared by the boot and main modules: the state lives on the global
// object, keyed by registered symbols.
export const STATE_KEY = Symbol.for("example.boot-state");
export const PENDING_KEY = Symbol.for("example.boot-pending");
export interface BootState {
startedAt: number;
steps: string[];
}
export const globalState = globalThis as {
[STATE_KEY]?: BootState;
[PENDING_KEY]?: Promise<string>;
};
export function readState(): BootState | undefined {
return globalState[STATE_KEY];
}
+8 -2
View File
@@ -1,6 +1,6 @@
// Ordinary code: identity-preserving calls, declaration-ordered literals,
// Latin-script comparisons, related-class casts and ISO dates report no
// divergence.
// Latin-script comparisons, related-class casts, ISO dates and views of the
// global object report no divergence.
interface Item {
name: string;
qty: number;
@@ -32,3 +32,9 @@ function wingsOf(a: Animal): number {
console.log(wingsOf(new Bird()), wingsOf(new Animal()));
console.log(["b", "A", "a"].sort((p, q) => p.localeCompare(q)).join(","));
console.log(Date.parse("2024-01-02"), new Date("2024-01-02T03:04:05+01:00").getTime());
// A type assertion of the global object denotes the global object itself.
const COUNT = Symbol.for("no-divergences.count");
const globalState = globalThis as { [COUNT]?: { n: number } };
globalState[COUNT] = { n: 1 };
console.log(globalState[COUNT]?.n);