Skip to content

Commit b063c1b

Browse files
committed
fix(cli): resolve race condition in locale processing
1 parent c2fe2ca commit b063c1b

File tree

3 files changed

+83
-6
lines changed

3 files changed

+83
-6
lines changed

.changeset/all-candies-reply.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"lingo.dev": patch
3+
---
4+
5+
fix racing condition where concurrent processing could use data from the wrong locale

packages/cli/src/cli/loaders/_utils.ts

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,10 @@ export function createLoader<I, O, C>(
4848
const state = {
4949
defaultLocale: undefined as string | undefined,
5050
originalInput: undefined as I | undefined | null,
51-
pullInput: undefined as I | undefined | null,
52-
pullOutput: undefined as O | undefined | null,
51+
// Store pullInput and pullOutput per-locale to avoid race conditions
52+
// when multiple locales are processed concurrently
53+
pullInputByLocale: new Map<string, I | null>(),
54+
pullOutputByLocale: new Map<string, O | null>(),
5355
initCtx: undefined as C | undefined,
5456
};
5557
return {
@@ -81,15 +83,15 @@ export function createLoader<I, O, C>(
8183
state.originalInput = input || null;
8284
}
8385

84-
state.pullInput = input;
86+
state.pullInputByLocale.set(locale, input || null);
8587
const result = await lDefinition.pull(
8688
locale,
8789
input,
8890
state.initCtx!,
8991
state.defaultLocale,
9092
state.originalInput!,
9193
);
92-
state.pullOutput = result;
94+
state.pullOutputByLocale.set(locale, result);
9395

9496
return result;
9597
},
@@ -101,13 +103,25 @@ export function createLoader<I, O, C>(
101103
throw new Error("Cannot push data without pulling first");
102104
}
103105

106+
// Use locale-specific pullInput/pullOutput if available,
107+
// otherwise fall back to the default locale's values for backward compatibility
108+
// (some loaders push for locales that were never explicitly pulled)
109+
const pullInput =
110+
state.pullInputByLocale.get(locale) ??
111+
state.pullInputByLocale.get(state.defaultLocale) ??
112+
null;
113+
const pullOutput =
114+
state.pullOutputByLocale.get(locale) ??
115+
state.pullOutputByLocale.get(state.defaultLocale) ??
116+
null;
117+
104118
const pushResult = await lDefinition.push(
105119
locale,
106120
data,
107121
state.originalInput,
108122
state.defaultLocale,
109-
state.pullInput!,
110-
state.pullOutput!,
123+
pullInput!,
124+
pullOutput!,
111125
);
112126
return pushResult;
113127
},

packages/cli/src/cli/loaders/po/index.spec.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,64 @@ msgstr ""`;
406406
expect(result).not.toContain('"Language: en\\n"');
407407
expect(result).toContain('msgstr "Hola mundo"');
408408
});
409+
410+
it("should preserve Language header for each locale when multiple target locales are pulled before push", async () => {
411+
// This test verifies the fix for a bug where pulling multiple target locales
412+
// before pushing would cause the Language header to be overwritten with the
413+
// wrong locale's value (e.g., es.po would get "Language: en" instead of "Language: es")
414+
const loader = createLoader();
415+
416+
const sourceInput = `msgid ""
417+
msgstr ""
418+
"Language: en\\n"
419+
"Content-Type: text/plain; charset=utf-8\\n"
420+
421+
#: hello.py:1
422+
msgid "Hello"
423+
msgstr "Hello"`;
424+
425+
const spanishInput = `msgid ""
426+
msgstr ""
427+
"Language: es\\n"
428+
"Content-Type: text/plain; charset=utf-8\\n"
429+
430+
#: hello.py:1
431+
msgid "Hello"
432+
msgstr ""`;
433+
434+
const portugueseInput = `msgid ""
435+
msgstr ""
436+
"Language: pt\\n"
437+
"Content-Type: text/plain; charset=utf-8\\n"
438+
439+
#: hello.py:1
440+
msgid "Hello"
441+
msgstr ""`;
442+
443+
await loader.pull("en", sourceInput);
444+
445+
// Pull multiple target locales (simulates concurrent processing)
446+
await loader.pull("es", spanishInput);
447+
await loader.pull("pt", portugueseInput);
448+
449+
const spanishResult = await loader.push("es", {
450+
Hello: { singular: "Hola", plural: null },
451+
});
452+
453+
const portugueseResult = await loader.push("pt", {
454+
Hello: { singular: "Olá", plural: null },
455+
});
456+
457+
expect(spanishResult).toContain('"Language: es\\n"');
458+
expect(spanishResult).not.toContain('"Language: pt\\n"');
459+
expect(spanishResult).not.toContain('"Language: en\\n"');
460+
expect(spanishResult).toContain('msgstr "Hola"');
461+
462+
expect(portugueseResult).toContain('"Language: pt\\n"');
463+
expect(portugueseResult).not.toContain('"Language: es\\n"');
464+
expect(portugueseResult).not.toContain('"Language: en\\n"');
465+
expect(portugueseResult).toContain('msgstr "Olá"');
466+
});
409467
});
410468

411469
function createLoader(params: PoLoaderParams = { multiline: false }) {

0 commit comments

Comments
 (0)