Skip to content

Commit 74d5353

Browse files
committed
fix: 修复原生 CI 的编码与路径兼容问题
保留 macOS JDK 17 的 NFD 主类验收,避免源文件名规范化干扰;以 UTF-8 请求和 ASCII 代理任务传递 Gradle 路径,归一化 Maven 模型输出,并在 Windows JDK 原生编码无法表示参数时提前报错,防止静默替换。 补充 Windows 进程树和参数能力验收;本机回归、双 JDK Gradle 验证与完整二进制 smoke 通过。
1 parent 2ad210c commit 74d5353

14 files changed

Lines changed: 328 additions & 44 deletions

File tree

‎README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,8 @@ java-run 启动独立 Java 进程,不模拟自定义 `JavaExec` / `bootRun`
169169

170170
当前支持 Maven `jar` 项目和启用了 Gradle Java 插件的项目,以及传统 `public static void main(String[])` 的 classpath 启动。主类发现只检查选定项目的已编译输出,不遍历所有依赖 Jar 寻找应用。JPMS、Android、native image、应用守护和热重启、部署不属于当前支持范围。
171171

172+
Windows 的 Java 原生启动器按系统代码页转换命令行参数。classpath 文件 URL 可以保留中文路径,但主类名、JVM / 应用参数和传给构建工具的参数仍需能被该编码完整表示。java-run 会检测 JDK 的 `native.encoding` 并提前拒绝无法表示的参数,避免它们静默变成 `?`。需要传递任意 Unicode 参数时,应按系统要求启用 UTF-8 区域设置;`-Dfile.encoding=UTF-8` 控制应用文件编码,不能替代这项设置。自定义构建入口自行切换 JDK 时,还需核对其实际使用的工具链。[JDK Windows 启动器说明](https://www.oracle.com/java/technologies/javase/21-0-9-relnotes.html)
173+
172174
开发与验证命令:
173175

174176
```sh

‎docs/implementation-plan.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,3 +54,13 @@
5454
核心实现提交为 `95f1b5f`,验收与 CI 提交为 `3005f8c`。评估与初始定位分别记录在 `4ba5822`、`40e87cf`,最终文档以本记录及 README 为准。
5555

5656
包版本仍为旧 `0.0.5`,README 明确当前契约尚未发布。下一步正式发布前需确定开源许可证与版本号,实际运行三种原生系统的 CI,并据结果确认支持矩阵。性能、命名运行配置和新增运行模式按 [后续技术路线](product-design.md#后续技术路线) 推进,不以未运行的计划代替验证。
57+
58+
## 首轮 CI 反馈与修复
59+
60+
用户推送后的 [首轮 Check](https://github.com/Vanisper/java-run/actions/runs/37256647164) 对应 `2ad210c`。Linux / JDK 17、21 与 macOS / JDK 21 完成全部验收;macOS / JDK 17 的 Unicode 测试及两组 Windows 回归失败。
61+
62+
macOS 的问题发生在测试源文件名:JDK 17 将文件路径转为 NFC,而 public 类使用 NFD。改用 ASCII 源文件名承载原有 NFD 类,保留主类发现和实际启动断言,JDK 17、21 都通过。
63+
64+
Windows 原生 JDK 将系统代码页无法表示的参数替换为 `?`。修复包括 Gradle 通过临时脚本中的 UTF-8 请求读取目录和模块、使用 ASCII 根代理任务,Java / Jar 使用相对受控路径,以及执行前按真实 JDK 编码检查用户参数。中文 classpath 目录继续保留,无法表示的原始参数以明确错误结束。Maven 有效模型的输出路径统一归一化,依赖元数据输出固定 UTF-8。另增加实际 Windows 批处理进程树清理用例。
65+
66+
修复后本地全量回归 91 项通过、3 项 Windows 专用测试跳过;新增 Maven 编码保护后专项 17 项通过。Gradle 8.14 在 JDK 17、21 下各 21 项验证通过,完整独立二进制 smoke 20 项全部通过。后续原生结果以 [分支 Check 运行](https://github.com/Vanisper/java-run/actions/workflows/check.yaml?query=branch%3Afeat%2Fopen-source-cli) 为准,不将本机结果替代 Windows 验收。

‎scripts/smoke.ts‎

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { spawn } from 'node:child_process';
22
import { cp, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
33
import os from 'node:os';
44
import path from 'node:path';
5+
import { assertJavaArguments, readJavaNativeEncoding } from '../src/process/java-arguments';
56

67
type Fixture = 'boot-single' | 'boot-reactor' | 'plain' | 'gradle-reactor';
78
type Suite = 'quick' | 'full';
@@ -24,12 +25,12 @@ interface ProcessResult {
2425
interface Expectations {
2526
code?: number;
2627
stdout?: string[];
28+
stderr?: string[];
2729
absent?: string[];
2830
}
2931

3032
const projectRoot = path.resolve(import.meta.dir, '..');
3133
const fixtureNames: Fixture[] = ['boot-single', 'boot-reactor', 'plain', 'gradle-reactor'];
32-
const specialValue = 'hello world #中文%';
3334

3435
function parseOptions(): SmokeOptions {
3536
let executable = path.join(projectRoot, 'dist', process.platform === 'win32' ? 'java-run.exe' : 'java-run');
@@ -130,6 +131,18 @@ async function main(): Promise<void> {
130131
await mkdir(repository, { recursive: true });
131132
await mkdir(gradleHome, { recursive: true });
132133
await mkdir(logDirectory);
134+
const java = process.env.JAVA_HOME ? path.join(process.env.JAVA_HOME, 'bin', process.platform === 'win32' ? 'java.exe' : 'java') : 'java';
135+
const nativeEncoding = await readJavaNativeEncoding(java, workspace);
136+
let unicodeArguments = true;
137+
try {
138+
await assertJavaArguments(java, ['hello world #中文%'], workspace);
139+
} catch (error) {
140+
if (!(error instanceof Error) || !error.message.includes('无法完整表示')) throw error;
141+
unicodeArguments = false;
142+
}
143+
const specialValue = unicodeArguments ? 'hello world #中文%' : 'hello world #%';
144+
const configValue = unicodeArguments ? 'config value #中文%' : 'config value #%';
145+
console.log(`Java native encoding: ${nativeEncoding}; Unicode argv: ${unicodeArguments ? 'supported' : 'explicit rejection required'}`);
133146
await writeFile(settings, '<settings xmlns="http://maven.apache.org/SETTINGS/1.2.0"/>\n');
134147
for (const fixture of fixtureNames) {
135148
const destination = path.join(workspace, `${fixture} 空格#中文%`);
@@ -151,6 +164,9 @@ async function main(): Promise<void> {
151164
for (const marker of expected.stdout ?? []) {
152165
if (!result.stdout.includes(marker)) violations.push(`缺少 stdout 标记: ${marker}`);
153166
}
167+
for (const marker of expected.stderr ?? []) {
168+
if (!result.stderr.includes(marker)) violations.push(`缺少 stderr 标记: ${marker}`);
169+
}
154170
for (const marker of expected.absent ?? []) {
155171
if ((result.stdout + result.stderr).includes(marker)) violations.push(`出现禁止标记: ${marker}`);
156172
}
@@ -227,17 +243,30 @@ async function main(): Promise<void> {
227243
await check('plain', argumentsFor('plain', ...directArguments, '--build-arg=-Pci', ...commonArguments), {
228244
stdout: ['[fixture] kind=plain', '[fixture] dependency-version=2.18.0', ...mavenMarkers, ...withoutTests],
229245
});
246+
if (!unicodeArguments) {
247+
const unsupportedValue = 'token-secret-中文';
248+
for (const [name, argument] of [
249+
['plain-unrepresentable-application-argument', `--arg=${unsupportedValue}`],
250+
['plain-unrepresentable-jvm-argument', `--jvm-arg=-Dfixture.jvm=${unsupportedValue}`],
251+
]) {
252+
await check(name!, argumentsFor('plain', ...directArguments, '--build=none', argument!), {
253+
code: 1,
254+
stderr: ['无法完整表示', '系统区域设置'],
255+
absent: ['[fixture]', unsupportedValue],
256+
});
257+
}
258+
}
230259
if (options.suite === 'full') {
231260
const configPath = path.join(projects.get('plain')!, '.java-run.json');
232261
await writeFile(configPath, JSON.stringify({
233262
mainClass: 'org.javarun.fixture.PlainApplication',
234263
build: 'none',
235264
buildArgs: mavenBuildArguments,
236-
jvmArgs: ['-Dfixture.jvm=config value #中文%'],
265+
jvmArgs: [`-Dfixture.jvm=${configValue}`],
237266
applicationArgs: ['--from-config'],
238267
}, null, 2));
239268
await check('project-config-default', [], {
240-
stdout: ['[fixture] kind=plain', '[fixture] jvm-value=config value #中文%', '[fixture] arg=--from-config'],
269+
stdout: ['[fixture] kind=plain', `[fixture] jvm-value=${configValue}`, '[fixture] arg=--from-config'],
241270
}, projects.get('plain')!);
242271
await check('project-config-override', ['--main=org.javarun.fixture.PlainApplication', '--jvm-arg=-Dfixture.jvm=cli value', '--arg=--from-cli'], {
243272
stdout: ['[fixture] jvm-value=cli value', '[fixture] arg=--from-config', '[fixture] arg=--from-cli'],

‎src/build-tools/gradle.ts‎

Lines changed: 45 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { createHash } from 'node:crypto';
22
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3-
import { isAbsolute, join, resolve } from 'node:path';
3+
import { isAbsolute, join, relative, resolve } from 'node:path';
44
import { CommandError, runCommand } from '../process/exec';
5+
import { assertJavaArguments } from '../process/java-arguments';
56
import type { BuildPlan, CommandSpec, PreparedProject, RunConfig } from '../core/types';
67

78
const projectFiles = ['settings.gradle', 'settings.gradle.kts', 'build.gradle', 'build.gradle.kts'];
@@ -52,23 +53,20 @@ function commandSpecification(config: RunConfig, workspace: string, discovery =
5253
throw new Error(`找不到 Gradle settings 或 build 文件:${config.cwd}`);
5354
}
5455
validateBuildArguments(config.buildArgs);
55-
const target = discovery ? ':' : targetPath(config.module);
56+
if (!discovery) targetPath(config.module);
5657
const paths = metadataPaths(workspace);
5758
const task = discovery ? `${paths.task}_discover` : paths.task;
5859
const build = discovery ? 'none' : config.build;
5960
const args = [
60-
'--no-daemon', '--console=plain', '--no-configuration-cache', '-I', paths.script,
61+
'--no-daemon', '--console=plain', '--no-configuration-cache', '-I', relative(config.cwd, paths.script),
6162
...config.buildArgs,
62-
`-DjavaRun.root=${resolve(config.cwd)}`,
63-
`-DjavaRun.target=${target}`,
64-
`-DjavaRun.output=${discovery ? join(resolve(workspace), 'gradle-projects.json') : paths.output}`,
6563
`-DjavaRun.metadataTask=${task}`,
6664
`-DjavaRun.build=${build}`,
6765
`-DjavaRun.includeTests=${discovery ? false : config.includeTests}`,
6866
`-DjavaRun.discover=${discovery}`,
6967
];
7068
if (build === 'none') args.push('-Porg.gradle.java.installations.auto-download=false');
71-
args.push(`${target === ':' ? ':' : `${target}:`}${task}`);
69+
args.push(`:${task}`);
7270
return { command: gradleCommand(config), args, cwd: config.cwd, stage: discovery ? 'Gradle 项目选择' : 'Gradle 项目准备' };
7371
}
7472

@@ -82,25 +80,30 @@ export function planGradle(config: RunConfig, workspace: string): BuildPlan {
8280
tool: 'gradle',
8381
commands: [commandSpecification(config, workspace)],
8482
notes: [
83+
`目标 Gradle 项目:${targetPath(config.module)}`,
8584
config.build === 'auto'
8685
? 'Gradle 任务图准备目标项目和运行依赖,不执行测试'
8786
: '不构建源码,要求目标项目和运行依赖已有可用产物',
8887
'主类、运行类路径和 Java 工具链将在执行 Gradle 后解析',
88+
'项目根、目标模块和输出路径由临时脚本读取 UTF-8 请求,不经 Java 原生命令行编码转换',
8989
],
9090
};
9191
}
9292

9393
const initScript = String.raw`
9494
import groovy.json.JsonOutput
95+
import groovy.json.JsonSlurper
9596
import org.gradle.api.GradleException
9697
import org.gradle.api.plugins.JavaApplication
9798
import org.gradle.api.plugins.JavaPluginExtension
9899
import org.gradle.api.tasks.SourceSetContainer
99100
import org.gradle.jvm.toolchain.JavaToolchainService
100101
102+
def javaRunRequest = new JsonSlurper().parseText(new String('@JAVA_RUN_REQUEST@'.decodeBase64(), 'UTF-8'))
103+
101104
gradle.projectsEvaluated {
102105
// init script 也会进入辅助构建,元数据任务只注册到请求的主构建
103-
def requestedRoot = new File(System.getProperty('javaRun.root')).canonicalFile
106+
def requestedRoot = new File(javaRunRequest.root).canonicalFile
104107
if (gradle.rootProject.projectDir.canonicalFile != requestedRoot) {
105108
return
106109
}
@@ -113,14 +116,14 @@ gradle.projectsEvaluated {
113116
}.sort { a, b -> a.path <=> b.path }.collect {
114117
[value: it.path, label: it.path + '(入口待解析)']
115118
}
116-
def output = new File(System.getProperty('javaRun.output'))
119+
def output = new File(javaRunRequest.output)
117120
output.parentFile.mkdirs()
118121
output.setText(JsonOutput.toJson(candidates), 'UTF-8')
119122
}
120123
}
121124
return
122125
}
123-
def requestedPath = System.getProperty('javaRun.target')
126+
def requestedPath = javaRunRequest.target
124127
def target = gradle.rootProject.findProject(requestedPath)
125128
if (target == null) {
126129
throw new GradleException("找不到 Gradle 项目:" + requestedPath + ",请用 --module 指定实际项目路径")
@@ -133,7 +136,7 @@ gradle.projectsEvaluated {
133136
def includeTests = Boolean.parseBoolean(System.getProperty('javaRun.includeTests'))
134137
def selected = sourceSets.getByName(includeTests ? 'test' : 'main')
135138
def included = includeTests ? [sourceSets.getByName('test'), sourceSets.getByName('main')] : [selected]
136-
target.tasks.register(taskName) {
139+
def metadataTask = target.tasks.register(taskName) {
137140
if (System.getProperty('javaRun.build') == 'auto') {
138141
dependsOn selected.classesTaskName
139142
dependsOn selected.runtimeClasspath.buildDependencies
@@ -155,7 +158,7 @@ gradle.projectsEvaluated {
155158
}
156159
def mainClass = application == null ? null : application.mainClass.orNull
157160
def jvmArgs = application == null ? [] : application.applicationDefaultJvmArgs.collect { it.toString() }
158-
def output = new File(System.getProperty('javaRun.output'))
161+
def output = new File(javaRunRequest.output)
159162
output.parentFile.mkdirs()
160163
output.setText(JsonOutput.toJson([
161164
directory: target.projectDir.absolutePath,
@@ -167,9 +170,35 @@ gradle.projectsEvaluated {
167170
]), 'UTF-8')
168171
}
169172
}
173+
if (target != gradle.rootProject) {
174+
// 根任务固定为 ASCII,目标模块的名称只从 UTF-8 请求读取
175+
gradle.rootProject.tasks.register(taskName) {
176+
dependsOn metadataTask
177+
}
178+
}
170179
}
171180
`.trimStart();
172181

182+
function initializationScript(config: RunConfig, workspace: string, discovery = false): string {
183+
const request = {
184+
root: resolve(config.cwd),
185+
target: discovery ? ':' : targetPath(config.module),
186+
output: discovery ? join(resolve(workspace), 'gradle-projects.json') : metadataPaths(workspace).output,
187+
};
188+
const encoded = Buffer.from(JSON.stringify(request), 'utf8').toString('base64');
189+
return initScript.replace('@JAVA_RUN_REQUEST@', encoded);
190+
}
191+
192+
async function executeGradle(spec: CommandSpec): Promise<void> {
193+
const java = process.env.JAVA_HOME
194+
? join(process.env.JAVA_HOME, 'bin', process.platform === 'win32' ? 'java.exe' : 'java')
195+
: 'java';
196+
await assertJavaArguments(java, spec.args, spec.cwd);
197+
console.error(`java-run:${spec.stage}`);
198+
const result = await runCommand(spec, { capture: true });
199+
if (result.exitCode !== 0) throw new CommandError(spec, result);
200+
}
201+
173202
function readProject(output: string): PreparedProject {
174203
let metadata: unknown;
175204
try {
@@ -210,12 +239,10 @@ export async function prepareGradle(config: RunConfig, workspace: string): Promi
210239
const plan = planGradle(config, workspace);
211240
const paths = metadataPaths(workspace);
212241
mkdirSync(resolve(workspace), { recursive: true });
213-
writeFileSync(paths.script, initScript);
242+
writeFileSync(paths.script, initializationScript(config, workspace));
214243
rmSync(paths.output, { force: true });
215244
const spec = plan.commands[0]!;
216-
console.error(`java-run:${spec.stage}`);
217-
const result = await runCommand(spec, { capture: true });
218-
if (result.exitCode !== 0) throw new CommandError(spec, result);
245+
await executeGradle(spec);
219246
return readProject(paths.output);
220247
}
221248

@@ -229,11 +256,9 @@ export async function discoverGradleProjects(config: RunConfig, workspace: strin
229256
const paths = metadataPaths(workspace);
230257
const output = join(resolve(workspace), 'gradle-projects.json');
231258
mkdirSync(resolve(workspace), { recursive: true });
232-
writeFileSync(paths.script, initScript);
259+
writeFileSync(paths.script, initializationScript(config, workspace, true));
233260
rmSync(output, { force: true });
234-
console.error(`java-run:${spec.stage}`);
235-
const result = await runCommand(spec, { capture: true });
236-
if (result.exitCode !== 0) throw new CommandError(spec, result);
261+
await executeGradle(spec);
237262
const candidates: unknown = JSON.parse(readFileSync(output, 'utf8'));
238263
if (!Array.isArray(candidates) || candidates.some(candidate =>
239264
!candidate || typeof candidate.value !== 'string' || !candidate.value.startsWith(':') || typeof candidate.label !== 'string')) {

0 commit comments

Comments
 (0)