diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 32a155330..90c6b8893 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -350,6 +350,10 @@ jobs:
if ($actual -ne "hello world") {
throw "unexpected scriptc output: $actual"
}
+ - name: Windows PE subsystem selection
+ env:
+ SCRIPTC_NO_CACHE: "0"
+ run: pnpm exec vitest run packages/cli/test/windows-subsystem.test.ts
- name: Windows CA-store differential
shell: pwsh
run: |
diff --git a/docs/src/app/cli/page.mdx b/docs/src/app/cli/page.mdx
index deef5f769..4050260ce 100644
--- a/docs/src/app/cli/page.mdx
+++ b/docs/src/app/cli/page.mdx
@@ -104,6 +104,9 @@ Prebuilds release runtime objects and native TLS/dynamic-engine archives for tar
--backend <c|llvm>
Code generator: llvm (default) or c (the readable debugging backend). On supported targets, LLVM-tier executable code generation uses the bundled helper and precompiled runtime pack before the platform link. Unset, a native build can fall back to C when the program is outside the LLVM tier. The production wasm32-wasi target never falls back: a missing LLVM lowering is SC3001.
+ --windows-subsystem <console|gui>
+ Select the subsystem of a Windows executable. console is the default; gui prevents Windows from opening a console window for the app. Valid only for executable builds targeting Windows, including --from-c.
+
--npm-static <pkg[,pkg…]|auto>
EXPERIMENTAL. Compile the named npm packages' shipped JS statically as program modules instead of embedding them for the engine (repeatable; auto opts in every eligible direct import). A package the preflight refuses falls back to the island with a coverage-report note. See npm Dependencies for maturity notes.
diff --git a/docs/src/app/platforms/page.mdx b/docs/src/app/platforms/page.mdx
index fd6ab05f8..25d4e8bb0 100644
--- a/docs/src/app/platforms/page.mdx
+++ b/docs/src/app/platforms/page.mdx
@@ -35,6 +35,8 @@ fib.exe: PE32+ executable (console) x86-64, for MS Windows
The full corpus runs in a differential lane against Windows Node, including `--dynamic` programs and `child_process`. The Windows fixture lanes also exercise real loopback traffic through `net`, `http`, `https`, `tls`, `http2`, `dgram`, and `dns`, plus the native `fetch` implementation (including redirects, streaming bodies, compression, cancellation, and proxy handling).
+Windows executable builds use the console subsystem by default. Set --windows-subsystem=gui for an application that owns its own window so Windows does not open an extra console window. This selects the PE subsystem at link time and works with either backend.
+
### iOS and Android (arm64, library mode)
Three mobile triples build **library-mode static archives** for an embedding app to link — a mobile app is the executable, so `scriptc build` without `--lib` rejects these targets with `SC3002`:
diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts
index 43b0e53b3..e965faadb 100644
--- a/packages/cli/src/main.ts
+++ b/packages/cli/src/main.ts
@@ -71,7 +71,7 @@ async function main(): Promise {
const [command, inputArg] = positionals;
if (command === "cache") {
if (inputArg !== "warm") fail(`unknown cache command "${inputArg ?? ""}" (supported: warm)\n\n${USAGE}`);
- if (values.lib || values.dynamic || values.backend !== undefined || values.emit !== undefined || values.print !== undefined || values["from-c"] || values.ffi !== undefined || values.profile !== undefined || (values["npm-static"] ?? []).length > 0 || values["provenance-sources"] || externalTypeArgs.length > 0 || values.out !== undefined || values["emit-ir"] || !values["keep-c"]) {
+ if (values.lib || values.dynamic || values.backend !== undefined || values.emit !== undefined || values.print !== undefined || values["from-c"] || values.ffi !== undefined || values.profile !== undefined || values["windows-subsystem"] !== undefined || (values["npm-static"] ?? []).length > 0 || values["provenance-sources"] || externalTypeArgs.length > 0 || values.out !== undefined || values["emit-ir"] || !values["keep-c"]) {
fail(`scriptc cache warm takes only native optimization/sanitizer options and profile names\n\n${USAGE}`);
}
const optimization = values.optimization;
@@ -119,9 +119,9 @@ async function main(): Promise {
if (inputArg) {
fail("scriptc build --lib takes no input positional: the profile names the entry module");
}
- if (values.dynamic || values.backend !== undefined || values.emit !== undefined || values.print !== undefined || values.optimization !== undefined || values.ffi !== undefined || (values["npm-static"] ?? []).length > 0 || externalTypeArgs.length > 0) {
+ if (values.dynamic || values.backend !== undefined || values.emit !== undefined || values.print !== undefined || values.optimization !== undefined || values.ffi !== undefined || values["windows-subsystem"] !== undefined || (values["npm-static"] ?? []).length > 0 || externalTypeArgs.length > 0) {
fail(
- "scriptc build --lib takes no --dynamic/--backend/--emit/--print/--optimization/--npm-static/--ffi/--external-types: the profile pins the emission and optimization, npm imports are judged automatically, outbound FFI belongs to executable builds, and external type mappings belong to coverage",
+ "scriptc build --lib takes no --dynamic/--backend/--emit/--print/--optimization/--windows-subsystem/--npm-static/--ffi/--external-types: the profile pins the emission and optimization, npm imports are judged automatically, outbound FFI belongs to executable builds, and external type mappings belong to coverage",
);
}
const profilePath = resolve(profileArg);
@@ -201,6 +201,13 @@ async function main(): Promise {
if (optimization !== undefined && optimization !== "release" && optimization !== "dev") {
fail(`unknown optimization "${optimization}" (supported: release, dev)\n\n${USAGE}`);
}
+ const windowsSubsystem = values["windows-subsystem"];
+ if (windowsSubsystem !== undefined && windowsSubsystem !== "console" && windowsSubsystem !== "gui") {
+ fail(`unknown Windows subsystem "${windowsSubsystem}" (supported: console, gui)\n\n${USAGE}`);
+ }
+ if (windowsSubsystem !== undefined && command === "coverage") {
+ fail(`--windows-subsystem is only supported for executable builds\n\n${USAGE}`);
+ }
const output = command === "coverage"
? null
: resolveOutputOptions(command, {
@@ -213,6 +220,7 @@ async function main(): Promise {
keepC: values["keep-c"],
sanitize: values.sanitize,
...(values.optimization === undefined ? {} : { optimization: values.optimization }),
+ ...(windowsSubsystem === undefined ? {} : { windowsSubsystem }),
...(values.ffi === undefined ? {} : { ffi: values.ffi }),
});
if (output !== null && !output.ok) fail(`${output.message}\n\n${USAGE}`);
@@ -258,6 +266,9 @@ async function main(): Promise {
}
if (output === null || !output.ok) throw new Error("internal output-option state");
+ if (windowsSubsystem !== undefined && sourceTargetPlatform() !== "win32") {
+ fail(`--windows-subsystem requires a Windows executable target\n\n${USAGE}`);
+ }
const { outDir, outPath, defaultOutputPath } = selectOutputPaths(input, output.cliOutputKind, values.out);
// SCRIPTC_CC remains a migration escape hatch for explicit C, sanitizer,
@@ -284,6 +295,7 @@ async function main(): Promise {
sanitize: values.sanitize,
dynamic: values.dynamic,
...(optimization !== undefined ? { optimization } : {}),
+ ...(windowsSubsystem !== undefined ? { windowsSubsystem } : {}),
});
return outPath;
}
@@ -297,6 +309,7 @@ async function main(): Promise {
dynamic: values.dynamic,
...(backend !== undefined ? { backend } : {}),
...(optimization !== undefined ? { optimization } : {}),
+ ...(windowsSubsystem !== undefined ? { windowsSubsystem } : {}),
...(npmStatic !== undefined ? { npmStatic } : {}),
...(ffiProfilePath !== undefined ? { ffiProfilePath } : {}),
...(printNativeLinkInfo ? { nativeLinkInfo: true } : {}),
diff --git a/packages/cli/src/output-options.ts b/packages/cli/src/output-options.ts
index 09e6593f7..0694debbd 100644
--- a/packages/cli/src/output-options.ts
+++ b/packages/cli/src/output-options.ts
@@ -9,6 +9,7 @@ export interface OutputOptionValues {
keepC: boolean;
sanitize: boolean;
optimization?: string;
+ windowsSubsystem?: string;
ffi?: string;
}
@@ -58,6 +59,9 @@ export function resolveOutputOptions(
if (values.fromC && emit !== "exe") {
return { ok: false, message: `--from-c only supports --emit=exe` };
}
+ if (values.windowsSubsystem !== undefined && emit !== "exe") {
+ return { ok: false, message: `--windows-subsystem is only supported with --emit=exe` };
+ }
if (emit === "ir" && backend !== undefined) {
return { ok: false, message: `--emit=ir cannot be combined with --backend; IR is emitted before backend selection` };
}
diff --git a/packages/cli/src/usage.ts b/packages/cli/src/usage.ts
index f5096fdf9..d3b0995ce 100644
--- a/packages/cli/src/usage.ts
+++ b/packages/cli/src/usage.ts
@@ -35,6 +35,9 @@ Options:
native optimization posture (default: release/-O2). dev
uses -O0 and stable cached LLVM object shards for faster
edits of large programs
+ --windows-subsystem
+ Windows executable subsystem (default: console). gui
+ prevents Windows from opening a console window
--from-c treat input as a C (or .ll) file (toolchain plumbing/debugging)
--keep-c keep the generated program TU next to the executable
(default; the .ll — or the .c under --backend=c or
@@ -74,6 +77,7 @@ export const CLI_OPTIONS = {
print: { type: "string" },
backend: { type: "string" },
optimization: { type: "string" },
+ "windows-subsystem": { type: "string" },
"from-c": { type: "boolean", default: false },
"keep-c": { type: "boolean", default: true },
"emit-ir": { type: "boolean", default: false },
diff --git a/packages/cli/test/output-options.test.ts b/packages/cli/test/output-options.test.ts
index ae1b7349a..17949eed8 100644
--- a/packages/cli/test/output-options.test.ts
+++ b/packages/cli/test/output-options.test.ts
@@ -46,6 +46,8 @@ describe("output option compatibility", () => {
[{ emit: "llvm", emitIr: true }, /emit-ir/],
[{ emit: "ir", emitIr: true }, /same output/],
[{ backend: "wat" }, /unknown backend/],
+ [{ emit: "obj", windowsSubsystem: "gui" }, /windows-subsystem/],
+ [{ emit: "llvm", windowsSubsystem: "gui" }, /windows-subsystem/],
] as const)("rejects %j", (override, message) => {
const result = resolveOutputOptions("build", { ...BASE, ...override });
expect(result).toEqual({ ok: false, message: expect.stringMatching(message) });
@@ -89,4 +91,11 @@ describe("output option compatibility", () => {
outputKind: "llvm",
});
});
+
+ test("executable outputs accept a Windows subsystem selection", () => {
+ expect(resolveOutputOptions("build", { ...BASE, windowsSubsystem: "gui" })).toMatchObject({
+ ok: true,
+ outputKind: "exe",
+ });
+ });
});
diff --git a/packages/cli/test/windows-subsystem.test.ts b/packages/cli/test/windows-subsystem.test.ts
new file mode 100644
index 000000000..961c3eb74
--- /dev/null
+++ b/packages/cli/test/windows-subsystem.test.ts
@@ -0,0 +1,106 @@
+import { execFile } from "node:child_process";
+import { createRequire } from "node:module";
+import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { dirname, join } from "node:path";
+import { promisify } from "node:util";
+import { afterEach, expect, test } from "vitest";
+
+const execFileAsync = promisify(execFile);
+const require = createRequire(import.meta.url);
+const repoRoot = join(import.meta.dirname, "../../..");
+const cliEntry = join(repoRoot, "packages/cli/src/main.ts");
+const tsxLoader = join(dirname(require.resolve("tsx/package.json")), "dist/loader.mjs");
+const dirs: string[] = [];
+
+afterEach(async () => {
+ await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
+});
+
+async function fixture() {
+ const dir = await mkdtemp(join(tmpdir(), "scriptc-windows-subsystem-"));
+ dirs.push(dir);
+ const entry = join(dir, "app.ts");
+ await writeFile(entry, 'console.log("subsystem");\n');
+ return { dir, entry };
+}
+
+function cli(args: string[], env: NodeJS.ProcessEnv = process.env) {
+ return execFileAsync(process.execPath, ["--import", tsxLoader, cliEntry, ...args], {
+ env,
+ maxBuffer: 4 * 1024 * 1024,
+ });
+}
+
+async function peSubsystem(path: string): Promise {
+ const bytes = await readFile(path);
+ expect(bytes.toString("ascii", 0, 2)).toBe("MZ");
+ const peOffset = bytes.readUInt32LE(0x3c);
+ expect(bytes.toString("ascii", peOffset, peOffset + 4)).toBe("PE\0\0");
+ const optional = peOffset + 24;
+ expect(bytes.readUInt16LE(optional)).toBe(0x20b);
+ return bytes.readUInt16LE(optional + 68);
+}
+
+test("rejects the subsystem option outside Windows executable builds", async () => {
+ const { dir, entry } = await fixture();
+ const invalid = [
+ ["build", entry, "--windows-subsystem=other"],
+ ["build", entry, "--emit=llvm", "--windows-subsystem=gui"],
+ ["build", entry, "--emit=obj", "--windows-subsystem=gui"],
+ ["coverage", entry, "--windows-subsystem=gui"],
+ ];
+ for (const args of invalid) {
+ await expect(cli(args)).rejects.toMatchObject({
+ code: 1,
+ stderr: expect.stringContaining("--windows-subsystem"),
+ });
+ }
+ await expect(cli(["build", entry, "--windows-subsystem=gui"], {
+ ...process.env,
+ SCRIPTC_TARGET: "wasm32-wasi",
+ })).rejects.toMatchObject({
+ code: 1,
+ stderr: expect.stringContaining("requires a Windows executable target"),
+ });
+ await expect(readFile(join(dir, ".scriptc", "app.wasm"))).rejects.toMatchObject({ code: "ENOENT" });
+});
+
+test.skipIf(process.platform !== "win32" && process.env["SCRIPTC_WIN_SUBSYSTEM"] !== "1")(
+ "builds console and GUI PE executables without cross-subsystem cache hits",
+ async () => {
+ const { dir, entry } = await fixture();
+ const env = process.platform === "win32"
+ ? { ...process.env, SCRIPTC_CACHE_DIR: join(dir, "cache") }
+ : {
+ ...process.env,
+ SCRIPTC_CC: "zigcc",
+ SCRIPTC_TARGET: "x86_64-windows-gnu",
+ SCRIPTC_CACHE_DIR: join(dir, "cache"),
+ ZIG_GLOBAL_CACHE_DIR: join(dir, "zig-cache"),
+ };
+ const llvmOut = join(dir, "llvm.exe");
+ const cOut = join(dir, "c.exe");
+ const build = async (backend: "llvm" | "c", out: string, subsystem?: "console" | "gui") => {
+ await cli([
+ "build", entry, `--backend=${backend}`, "-o", out,
+ ...(subsystem === undefined ? [] : [`--windows-subsystem=${subsystem}`]),
+ ], env);
+ return peSubsystem(out);
+ };
+ expect(await build("llvm", llvmOut)).toBe(3);
+ expect(await build("llvm", llvmOut, "gui")).toBe(2);
+ expect(await build("llvm", llvmOut)).toBe(3);
+ expect(await build("llvm", llvmOut, "console")).toBe(3);
+ expect(await build("c", cOut, "gui")).toBe(2);
+ const cSource = join(dir, "from-c.c");
+ const fromCOut = join(dir, "from-c.exe");
+ await writeFile(cSource, "int main(void) { return 0; }\n");
+ await cli(["build", cSource, "--from-c", "--windows-subsystem=gui", "-o", fromCOut], env);
+ expect(await peSubsystem(fromCOut)).toBe(2);
+ if (process.platform === "win32") {
+ expect((await execFileAsync(cOut)).stdout.trim()).toBe("subsystem");
+ }
+ },
+ 180_000,
+);
diff --git a/packages/compiler/src/backend/link-plan.ts b/packages/compiler/src/backend/link-plan.ts
index de4fd8cfd..26bba99e5 100644
--- a/packages/compiler/src/backend/link-plan.ts
+++ b/packages/compiler/src/backend/link-plan.ts
@@ -12,7 +12,9 @@ import type { NativeArtifactDependency } from "./native-toolchain.js";
import { loadRuntimePack, type RuntimePackSelection } from "./runtime-pack.js";
import {
executableOptimizationLinkerArgs,
+ windowsSubsystemLinkerArgs,
type NativeTargetSpec,
+ type WindowsSubsystem,
} from "./targets.js";
export interface NativeLinkPlan {
@@ -36,6 +38,7 @@ export async function createNativeLinkPlan(options: {
features: NativeLinkFeatures;
ffi: FfiProfile | null;
optimization: "release" | "dev";
+ windowsSubsystem?: WindowsSubsystem;
programObjectDependencies?: readonly NativeArtifactDependency[];
env?: NodeJS.ProcessEnv;
resolver?: (specifier: string) => string;
@@ -59,6 +62,7 @@ export async function createNativeLinkPlan(options: {
index > 0 && args[index - 1] === "-target" ? options.target.linkerTargetTriple : arg
),
...executableOptimizationLinkerArgs(options.target.platform, options.optimization),
+ ...windowsSubsystemLinkerArgs(options.target.platform, options.windowsSubsystem),
],
dependencyPaths: [
...runtimePack.dependencyPaths,
diff --git a/packages/compiler/src/backend/native-toolchain.ts b/packages/compiler/src/backend/native-toolchain.ts
index c4b82d87a..2eedb66d9 100644
--- a/packages/compiler/src/backend/native-toolchain.ts
+++ b/packages/compiler/src/backend/native-toolchain.ts
@@ -10,7 +10,7 @@ import { performance } from "node:perf_hooks";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { localizeElfObject, mergeAndLocalizeCoffObjects } from "./object-localize.js";
-import { executableOptimizationLinkerArgs } from "./targets.js";
+import { executableOptimizationLinkerArgs, windowsSubsystemLinkerArgs, type WindowsSubsystem } from "./targets.js";
import {
createVendorArchives,
MBEDTLS_VERSION,
@@ -285,6 +285,8 @@ export interface CcOptions {
* executable lane; dev selects -O0 and may compile a caller-provided LLVM
* shard set into independently cached objects before the final link. */
optimization?: "release" | "dev";
+ /** PE executable subsystem; omitted and console use the driver default. */
+ windowsSubsystem?: WindowsSubsystem;
/** Optional equivalent LLVM modules for dev compilation. Unsupported
* targets or merge failures fall back to the canonical cPath TU. */
programShards?: readonly { name: string; source: string }[];
@@ -4053,12 +4055,14 @@ async function compileCInternal(
? EXECUTABLE_RUNTIME_SOURCES.filter((source) => source !== "scr_child.c")
: EXECUTABLE_RUNTIME_SOURCES;
const executableSectionFlags = executableSectionEliminationFlags(targetPlatform(driver));
+ const windowsSubsystemArgs = windowsSubsystemLinkerArgs(targetPlatform(driver), opts.windowsSubsystem);
const executableLinkFlags = [
...executableSectionFlags.link,
...executableOptimizationLinkerArgs(
targetPlatform(driver),
optimization,
),
+ ...windowsSubsystemArgs,
];
// scr_async.c submits callback-style filesystem work to a native worker.
// POSIX drivers need the thread compile/link mode; win32 uses CreateThread.
diff --git a/packages/compiler/src/backend/runtime-pack.test.ts b/packages/compiler/src/backend/runtime-pack.test.ts
index 38a2e2752..a1b05b9ce 100644
--- a/packages/compiler/src/backend/runtime-pack.test.ts
+++ b/packages/compiler/src/backend/runtime-pack.test.ts
@@ -21,7 +21,7 @@ import {
platformLinkerSupportsPersistentCache,
resolvePlatformLinker,
} from "./linker.js";
-import { MACOS_ARM64_TARGET } from "./targets.js";
+import { MACOS_ARM64_TARGET, WINDOWS_X64_MSVC_TARGET, type NativeTargetSpec } from "./targets.js";
const VERSION = compilerReleaseVersion();
@@ -58,11 +58,11 @@ const BASE: NativeLinkFeatures = {
tlsCa: false,
};
-async function fixture() {
+async function fixture(target: NativeTargetSpec = MACOS_ARM64_TARGET) {
const root = await mkdtemp(join(tmpdir(), "scriptc-runtime-pack-unit-"));
const packagePath = join(root, "package.json");
await writeFile(packagePath, JSON.stringify({
- name: "@scriptc/runtime-darwin-arm64",
+ name: target.runtimePackPackage,
version: VERSION,
}));
const artifact = async (path: string, bytes: string) => {
@@ -93,20 +93,20 @@ async function fixture() {
const manifest: RuntimePackManifest = {
schema: "scriptc.runtime-pack.v1",
format: 1,
- package: "@scriptc/runtime-darwin-arm64",
+ package: target.runtimePackPackage,
version: VERSION,
target: {
- name: "macos-arm64",
- llvm_triple: "arm64-apple-macosx14.0.0",
- architecture: "arm64",
- object_format: "macho",
- minimum_os: "14.0",
+ name: target.name,
+ llvm_triple: target.llvmTriple,
+ architecture: target.architecture,
+ object_format: target.objectFormat,
+ minimum_os: target.minimumOs,
},
runtime_abi: { version: 1, marker: "scr_runtime_abi_v1" },
compiler: {
command: "clang",
identity: "fixture clang",
- target: "arm64-apple-macosx14.0.0",
+ target: target.llvmTriple,
},
macros: {
executable: ["SCR_DYNAMIC", "SCR_TEXT_DECODER_LEGACY"],
@@ -194,6 +194,25 @@ describe("runtime pack manifests", () => {
expect(plan.driverFlags).toContain("-Wl,-dead_strip");
});
+ test("Windows runtime-pack links select the GUI subsystem only when requested", async () => {
+ const { packagePath, root } = await fixture(WINDOWS_X64_MSVC_TARGET);
+ const options = {
+ target: WINDOWS_X64_MSVC_TARGET,
+ programObject: join(root, "program.obj"),
+ outPath: join(root, "program.exe"),
+ features: BASE,
+ ffi: null,
+ optimization: "release" as const,
+ resolver: () => packagePath,
+ };
+ const defaultPlan = await createNativeLinkPlan(options);
+ const consolePlan = await createNativeLinkPlan({ ...options, windowsSubsystem: "console" });
+ const guiPlan = await createNativeLinkPlan({ ...options, windowsSubsystem: "gui" });
+ expect(defaultPlan.driverFlags).not.toContain("-Wl,--subsystem,windows");
+ expect(consolePlan.driverFlags).toEqual(defaultPlan.driverFlags);
+ expect(guiPlan.driverFlags).toEqual([...defaultPlan.driverFlags, "-Wl,--subsystem,windows"]);
+ });
+
test("selection chooses the most-specific variant and feature archive", async () => {
const { packagePath } = await fixture();
const resolver = () => packagePath;
diff --git a/packages/compiler/src/backend/targets.test.ts b/packages/compiler/src/backend/targets.test.ts
index fe6671bf2..be07d3d4a 100644
--- a/packages/compiler/src/backend/targets.test.ts
+++ b/packages/compiler/src/backend/targets.test.ts
@@ -11,6 +11,7 @@ import {
executableOptimizationLinkerArgs,
nativeCodegenTarget,
nativeCodegenTargetRefusal,
+ windowsSubsystemLinkerArgs,
} from "./targets.js";
describe("native code-generation targets", () => {
@@ -66,4 +67,12 @@ describe("native code-generation targets", () => {
expect(executableOptimizationLinkerArgs("wasi", "dev")).toEqual([]);
expect(executableOptimizationLinkerArgs("linux", "release")).toEqual([]);
});
+
+ test("selects the PE GUI subsystem without changing default console links", () => {
+ expect(windowsSubsystemLinkerArgs("win32", undefined)).toEqual([]);
+ expect(windowsSubsystemLinkerArgs("win32", "console")).toEqual([]);
+ expect(windowsSubsystemLinkerArgs("win32", "gui")).toEqual(["-Wl,--subsystem,windows"]);
+ expect(() => windowsSubsystemLinkerArgs("linux", "gui")).toThrow("Windows executable target");
+ expect(() => windowsSubsystemLinkerArgs("win32", "other" as "gui")).toThrow("unknown Windows subsystem");
+ });
});
diff --git a/packages/compiler/src/backend/targets.ts b/packages/compiler/src/backend/targets.ts
index 58bb53c2d..574a386f4 100644
--- a/packages/compiler/src/backend/targets.ts
+++ b/packages/compiler/src/backend/targets.ts
@@ -23,6 +23,24 @@ export type NativeTargetName =
export type NativeObjectFormat = "macho" | "elf" | "coff" | "wasm";
export type NativeTargetPlatform = "darwin" | "linux" | "win32" | "wasi";
+export type WindowsSubsystem = "console" | "gui";
+
+/** Select the PE subsystem through either supported compiler-driver route.
+ * An omitted or explicit console selection retains the driver's default. */
+export function windowsSubsystemLinkerArgs(
+ platform: string,
+ subsystem: WindowsSubsystem | undefined,
+): string[] {
+ if (subsystem === undefined) return [];
+ if (subsystem !== "console" && subsystem !== "gui") {
+ throw new Error(`unknown Windows subsystem '${String(subsystem)}' (supported: console, gui)`);
+ }
+ if (platform !== "win32") {
+ throw new Error("--windows-subsystem requires a Windows executable target");
+ }
+ return subsystem === "gui" ? ["-Wl,--subsystem,windows"] : [];
+}
+
/** Linker flags that belong to an optimization posture rather than the
* target ABI. Zig's WASI runtime objects carry DWARF custom sections even
* when compiled with -O2; release executables discard that non-runtime
diff --git a/packages/compiler/src/executable/executable-cache.ts b/packages/compiler/src/executable/executable-cache.ts
index 3ec2f4c2e..ffbf939b3 100644
--- a/packages/compiler/src/executable/executable-cache.ts
+++ b/packages/compiler/src/executable/executable-cache.ts
@@ -85,6 +85,8 @@ export interface EarlyExecutableCacheOptions {
backend: "auto" | "c" | "llvm";
/** Omitted is the historical release posture and preserves v1 keys. */
optimization?: "dev";
+ /** Omitted for the default Windows console subsystem. */
+ windowsSubsystem?: "gui";
npmStatic: readonly string[] | "auto" | null;
/** Raw manifest identity: path and bytes. Native archives remain under the
* stricter native cache's independent dependency validation. */
@@ -186,6 +188,7 @@ function cacheKey(options: EarlyExecutableCacheOptions): string {
options.dynamic ? "dynamic" : "static",
options.backend,
...(options.optimization === "dev" ? ["optimization-dev"] : []),
+ ...(options.windowsSubsystem === "gui" ? ["windows-subsystem-gui"] : []),
options.npmStatic === null
? ""
: options.npmStatic === "auto"
@@ -213,6 +216,7 @@ function routeKey(options: EarlyExecutableRouteOptions): string {
.update(options.dynamic ? "dynamic" : "static").update("\0")
.update(options.backend).update("\0");
if (options.optimization === "dev") hash.update("optimization-dev\0");
+ if (options.windowsSubsystem === "gui") hash.update("windows-subsystem-gui\0");
hash
.update(options.npmStatic === null
? ""
diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts
index 55b76e521..9773f0319 100644
--- a/packages/compiler/src/index.ts
+++ b/packages/compiler/src/index.ts
@@ -21,6 +21,7 @@ import { emitLlvmModule, LlvmUnsupportedError } from "./backend/llvm/emitter.js"
import { emitNativeArtifact, NativeCodegenError } from "./backend/native-codegen.js";
import { privateSiblingPath } from "./backend/build-cache.js";
import { nativeCodegenTarget, nativeCodegenTargetRefusal } from "./backend/targets.js";
+import { windowsSubsystemLinkerArgs, type WindowsSubsystem } from "./backend/targets.js";
import { createNativeLinkInfo, type NativeLinkInfo } from "./backend/native-link-info.js";
import { RuntimePackError } from "./backend/runtime-pack.js";
import { createNativeLinkPlan } from "./backend/link-plan.js";
@@ -80,6 +81,7 @@ export {
RUNTIME_ABI_VERSION,
} from "./backend/runtime-abi.js";
export type { NativeLinkInfo, NativeLinkFeatures } from "./backend/native-link-info.js";
+export type { WindowsSubsystem } from "./backend/targets.js";
export { InternalCompilerError } from "./errors.js";
export {
@@ -223,6 +225,9 @@ export interface CompileBaseOptions {
/** Native optimization posture. Release is the shipped -O2 default; dev
* uses -O0 and stable multi-TU object caching for large LLVM programs. */
optimization?: "release" | "dev";
+ /** Windows PE executable subsystem. Console is the default; GUI suppresses
+ * automatic console-window creation. Only valid for Windows executables. */
+ windowsSubsystem?: WindowsSubsystem;
/** --npm-static: package names whose shipped, unminified JS compiles
* STATICALLY as program modules (inference types the bodies; statements
* the lowering cannot prove become runtime fences). "auto" opts in every
@@ -1073,6 +1078,7 @@ async function compileExecutableNative(
outPath: string,
sanitize: boolean,
ffi: FfiProfile | null,
+ windowsSubsystem?: WindowsSubsystem,
programSplit: ReturnType = null,
programObjectDependencies: readonly NativeArtifactDependency[] = [],
onArtifactReady?: NonNullable[0]["onArtifactReady"]>,
@@ -1089,6 +1095,7 @@ async function compileExecutableNative(
features,
ffi,
optimization: features.optimization ?? "release",
+ ...(windowsSubsystem === undefined ? {} : { windowsSubsystem }),
programObjectDependencies,
});
const cacheableLinker =
@@ -1125,6 +1132,7 @@ async function compileExecutableNative(
outPath,
cacheIdentity: "scriptc-generated-v1",
...(features.optimization === "dev" ? { optimization: "dev" as const } : {}),
+ ...(windowsSubsystem === undefined ? {} : { windowsSubsystem }),
...(effectiveProgramSplit === null
? {}
: {
@@ -1230,6 +1238,13 @@ async function compileTracked(
): Promise {
entryPath = resolve(entryPath);
const outputKind = opts.outputKind ?? "exe";
+ if (opts.windowsSubsystem !== undefined && outputKind !== "exe") {
+ return {
+ ok: false,
+ diagnostics: [nativeCodegenDiag("SC3002", "--windows-subsystem is only supported for executable output", entryPath)],
+ sourceTexts: new Map(),
+ };
+ }
if (opts.nativeLinkInfo === true && outputKind !== "obj") {
return {
ok: false,
@@ -1318,6 +1333,15 @@ async function compileTracked(
}
}
}
+ try {
+ windowsSubsystemLinkerArgs(buildPlatform, opts.windowsSubsystem);
+ } catch (err) {
+ return {
+ ok: false,
+ diagnostics: [nativeCodegenDiag("SC3002", err instanceof Error ? err.message : String(err), entryPath)],
+ sourceTexts: new Map(),
+ };
+ }
// Mobile triples are library-mode targets: the archive an embedding app
// links is the artifact, and only the library-admissible runtime surface
// is verified on those device classes. The executable lane refuses before
@@ -1365,6 +1389,7 @@ async function compileTracked(
dynamic: opts.dynamic ?? false,
backend: opts.backend ?? "auto",
...(opts.optimization === "dev" ? { optimization: "dev" as const } : {}),
+ ...(opts.windowsSubsystem === "gui" ? { windowsSubsystem: "gui" as const } : {}),
npmStatic: opts.npmStatic ?? null,
ffiProfile:
opts.ffiProfilePath === undefined || ffiProfileBytes === null
@@ -1466,6 +1491,7 @@ async function compileTracked(
opts.outPath,
opts.sanitize ?? false,
ffi,
+ opts.windowsSubsystem,
null,
nativeProgramObject?.dependencies,
opts.nativeProgramObject === true ? undefined : async ({ dependencies }) => {
@@ -1788,6 +1814,7 @@ async function compileTracked(
opts.outPath,
opts.sanitize ?? false,
ffi,
+ opts.windowsSubsystem,
programSplit,
nativeProgramObject?.dependencies,
opts.nativeProgramObject === true ? undefined : async ({ dependencies }) => {
diff --git a/packages/compiler/test/source-output.test.ts b/packages/compiler/test/source-output.test.ts
index f86c15e6e..7e61f7cf0 100644
--- a/packages/compiler/test/source-output.test.ts
+++ b/packages/compiler/test/source-output.test.ts
@@ -28,6 +28,17 @@ test("serialized IR is the primary artifact and round-trips through validation",
expect(validateModule(module)).toEqual([]);
});
+test("Windows subsystem selection refuses non-executable compiler output", async () => {
+ const { entry, outDir } = await fixture();
+ const outPath = join(outDir, "main.ir.json");
+ const result = await compile(entry, { outDir, outPath, outputKind: "ir", windowsSubsystem: "gui" });
+ expect(result).toMatchObject({
+ ok: false,
+ diagnostics: [{ code: "SC3002", message: expect.stringContaining("only supported for executable output") }],
+ });
+ await expect(readFile(outPath)).rejects.toMatchObject({ code: "ENOENT" });
+});
+
test("C and LLVM are exact primary artifacts and never create an executable", async () => {
const { entry, outDir } = await fixture();
const cPath = join(outDir, "exact.output");